implements page-numbers-plan

This commit is contained in:
rehlert committed 2026-10-28 09:25:15 +01:00
1 parent 39da8dac4d
commit b5279434b2
19 files changed
+1823 -339

No files matched your search

+7 -3
View File
@@ -12,8 +12,11 @@ export type BatchItem = {
export type BatchProcessResult = { bytes: Uint8Array; name: string };
export type BatchRunOptions = {
/** Error message written to a row when `process` throws for it. */
errorMessage?: string;
/**
* Error message written to a row when `process` throws for it. A function
* receives the thrown error so tools can surface a specific German message.
*/
errorMessage?: string | ((error: unknown, file: File) => string);
/**
* Optional predicate consulted before each item; when it returns `false`
* (for example because the row was removed mid-run), the item is skipped.
@@ -58,7 +61,8 @@ export const runBatch = async (
status: 'error',
resultBytes: undefined,
resultName: undefined,
errorMessage
errorMessage:
typeof errorMessage === 'function' ? errorMessage(error, item.file) : errorMessage
});
}
}
+26
View File
@@ -0,0 +1,26 @@
const ENCRYPTED_PDF_PATTERN = /password|encrypted|encryption|verschl/i;
/**
* pdf-lib (`EncryptedPDFError`) and pdfjs (`PasswordException`) both surface
* password protection through the error name/message. Tools use this to show a
* single German hint instead of a generic failure.
*/
export const isEncryptedPdfError = (error: unknown): boolean => {
const message = error instanceof Error ? `${error.name} ${error.message}` : String(error);
return ENCRYPTED_PDF_PATTERN.test(message);
};
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.';
/** 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;
};
+23
View File
@@ -50,3 +50,26 @@ export const parseHexColor = (hexColor: string): RGB => {
Number.parseInt(match[3], 16) / 255
);
};
/**
* 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%;';
}
};
+81
View File
@@ -0,0 +1,81 @@
import { PDFDocument, StandardFonts } from 'pdf-lib';
import { parseHexColor, type OverlayPosition } from './pdf-overlay';
import { drawTextOverlay } from './pdf-text-overlay';
export const MIN_START_NUMBER = 1;
export const MAX_START_NUMBER = 99999;
export const PAGE_NUMBER_MARGIN = 28;
export const PAGE_NUMBER_COLOR = '#1a1a1a';
export const MIN_FONT_SIZE = 8;
export const MAX_FONT_SIZE = 18;
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;
export type PaginationFormat = (typeof PAGINATION_FORMATS)[number]['value'];
export type PaginationTextScope = 'all' | 'first';
export type PaginationOptions = {
startNumber: number;
freeText: string;
textScope: PaginationTextScope;
format: PaginationFormat;
position: OverlayPosition;
fontSize: number;
};
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;
/**
* 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.
*/
export const buildPaginationLabel = (
pageIndex: number,
options: Pick<PaginationOptions, 'startNumber' | 'freeText' | 'textScope' | 'format'>
): string => {
const numberText = `${getFormatPrefix(options.format)}${normalizeStartNumber(options.startNumber) + pageIndex}`;
const freeText = options.freeText.trim();
const showsFreeText = options.textScope === 'all' || pageIndex === 0;
return showsFreeText && freeText ? `${freeText}, ${numberText}` : numberText;
};
/**
* Stamps every page with its ascending sheet number. Rotated scans are
* compensated so the number stays at the visual position.
*/
export const paginatePdf = async (
bytes: Uint8Array,
options: PaginationOptions
): Promise<Uint8Array> => {
const document = await PDFDocument.load(new Uint8Array(bytes));
const font = await document.embedFont(StandardFonts.Helvetica);
const color = parseHexColor(PAGE_NUMBER_COLOR);
for (const [pageIndex, page] of document.getPages().entries()) {
drawTextOverlay(page, {
font,
text: buildPaginationLabel(pageIndex, options),
size: options.fontSize,
color,
position: options.position,
margin: PAGE_NUMBER_MARGIN,
padding: 0,
compensatePageRotation: true
});
}
return document.save();
};
+248
View File
@@ -0,0 +1,248 @@
import { degrees, type PDFFont, type PDFPage, type RGB } from 'pdf-lib';
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;
/** 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.';
export type TextOverlayOptions = {
font: PDFFont;
text: string;
size: number;
color: RGB;
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. */
padding?: number;
framed?: boolean;
borderWidth?: number;
/** Additional counter-clockwise rotation of the mark in degrees. */
rotation?: number;
/** Font size floor while shrinking to fit the page. */
minSize?: number;
/**
* 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.
*/
compensatePageRotation?: boolean;
tooLongMessage?: string;
};
type TextBox = {
size: number;
textHeight: number;
boxWidth: number;
boxHeight: number;
};
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.'
);
}
};
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;
let size = requestedSize;
let metrics = measureText(font, text, size);
while (
size > minSize &&
(metrics.width + padding * 2 > availableWidth || metrics.height + padding * 2 > availableHeight)
) {
size = Math.max(minSize, size - 0.5);
metrics = measureText(font, text, size);
}
if (
metrics.width + padding * 2 > availableWidth ||
metrics.height + padding * 2 > availableHeight
) {
throw new Error(tooLongMessage);
}
return {
size,
textHeight: metrics.height,
boxWidth: metrics.width + padding * 2,
boxHeight: metrics.height + padding * 2
};
};
/**
* Lower-left corner for a (possibly rotated) box inside the given page size.
* The bounding box of the rotated mark is aligned to the requested position, so
* tilted text never crosses the page edge.
*/
export const getRotatedBoxPlacement = (
pageWidth: number,
pageHeight: number,
boxWidth: number,
boxHeight: number,
position: OverlayPosition,
angle: number,
margin = DEFAULT_MARGIN
) => {
const radians = (angle * Math.PI) / 180;
const cosine = Math.cos(radians);
const sine = Math.sin(radians);
const rotatedCorners = [
{ x: 0, y: 0 },
{ x: boxWidth * cosine, y: boxWidth * sine },
{ x: -boxHeight * sine, y: boxHeight * cosine },
{
x: boxWidth * cosine - boxHeight * sine,
y: boxWidth * sine + boxHeight * cosine
}
];
const minX = Math.min(...rotatedCorners.map((corner) => corner.x));
const maxX = Math.max(...rotatedCorners.map((corner) => corner.x));
const minY = Math.min(...rotatedCorners.map((corner) => corner.y));
const maxY = Math.max(...rotatedCorners.map((corner) => corner.y));
const boundingCoordinates = getPositionCoords(
pageWidth,
pageHeight,
maxX - minX,
maxY - minY,
position,
margin
);
return {
x: boundingCoordinates.x - minX,
y: boundingCoordinates.y - minY,
cosine,
sine
};
};
/** Normalised page rotation; anything that is not a multiple of 90 is ignored. */
const getPageRotation = (page: PDFPage): number => {
const angle = Math.round(page.getRotation().angle / 90) * 90;
return ((angle % 360) + 360) % 360;
};
/** Displayed page size after applying `/Rotate`. */
const getVisualPageSize = (pageWidth: number, pageHeight: number, pageRotation: number) =>
pageRotation % 180 === 0
? { width: pageWidth, height: pageHeight }
: { width: pageHeight, height: pageWidth };
/**
* 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.
*/
const toUserSpace = (
point: { x: number; y: number },
pageWidth: number,
pageHeight: number,
pageRotation: number
) => {
switch (pageRotation) {
case 90:
return { x: pageWidth - point.y, y: point.x };
case 180:
return { x: pageWidth - point.x, y: pageHeight - point.y };
case 270:
return { x: point.y, y: pageHeight - point.x };
default:
return point;
}
};
/**
* 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.
*/
export const drawTextOverlay = (page: PDFPage, options: TextOverlayOptions): void => {
const {
font,
text,
size,
color,
position,
margin = DEFAULT_MARGIN,
padding = DEFAULT_PADDING,
framed = false,
borderWidth = DEFAULT_BORDER_WIDTH,
rotation = 0,
minSize = DEFAULT_MIN_SIZE,
compensatePageRotation = false,
tooLongMessage = DEFAULT_TOO_LONG_MESSAGE
} = options;
const { width, height } = page.getSize();
const pageRotation = compensatePageRotation ? getPageRotation(page) : 0;
const visual = getVisualPageSize(width, height, pageRotation);
const box = fitTextToPage(
font,
text,
size,
visual.width,
visual.height,
margin,
padding,
minSize,
tooLongMessage
);
const placement = getRotatedBoxPlacement(
visual.width,
visual.height,
box.boxWidth,
box.boxHeight,
position,
rotation,
margin
);
const origin = toUserSpace({ x: placement.x, y: placement.y }, 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;
if (framed) {
page.drawRectangle({
x: origin.x,
y: origin.y,
width: box.boxWidth,
height: box.boxHeight,
borderColor: color,
borderWidth,
rotate: degrees(angle)
});
}
page.drawText(text, {
x: origin.x + offsetX * cosine - offsetY * sine,
y: origin.y + offsetX * sine + offsetY * cosine,
size: box.size,
font,
color,
rotate: degrees(angle)
});
};
+100 -9
View File
@@ -11,7 +11,27 @@ export type PdfDocumentHandle = {
destroy: () => void;
};
export async function loadPdfDocument(data: Uint8Array): Promise<PdfDocumentHandle> {
/** Pixel scale used for on-demand page previews. */
export const PDF_PREVIEW_SCALE = 1.25;
export type PdfPagePreview = {
pageNumber: number;
imageUrl: string;
width: number;
height: number;
};
/**
* Renders one page at a time. Used by tools that operate on a whole document
* (pagination) where rasterising every page up front would exhaust memory.
*/
export type PdfPageRenderer = {
pageCount: number;
render: (pageNumber: number) => Promise<PdfPagePreview>;
destroy: () => void;
};
const loadPdfJs = async () => {
const pdfjsLib = await import('pdfjs-dist');
const { getDocument, GlobalWorkerOptions } = pdfjsLib;
@@ -19,6 +39,20 @@ export async function loadPdfDocument(data: Uint8Array): Promise<PdfDocumentHand
(module) => module.default
);
return getDocument;
};
const canvasToPngBlob = (canvas: HTMLCanvasElement) =>
new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(result) => (result ? resolve(result) : reject(new Error('Could not encode PDF thumbnail.'))),
'image/png'
);
});
export async function loadPdfDocument(data: Uint8Array): Promise<PdfDocumentHandle> {
const getDocument = await loadPdfJs();
const loadingTask = getDocument({ data: new Uint8Array(data) });
const thumbnails: PdfThumbnail[] = [];
@@ -36,17 +70,10 @@ export async function loadPdfDocument(data: Uint8Array): Promise<PdfDocumentHand
if (!canvasContext) throw new Error('Could not create thumbnail canvas context.');
await page.render({ canvas, canvasContext, viewport }).promise;
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(result) =>
result ? resolve(result) : reject(new Error('Could not encode PDF thumbnail.')),
'image/png'
);
});
thumbnails.push({
pageNumber,
imageUrl: URL.createObjectURL(blob),
imageUrl: URL.createObjectURL(await canvasToPngBlob(canvas)),
width: viewport.width,
height: viewport.height
});
@@ -69,3 +96,67 @@ export async function loadPdfDocument(data: Uint8Array): Promise<PdfDocumentHand
throw error;
}
}
export async function loadPdfPageRenderer(data: Uint8Array): Promise<PdfPageRenderer> {
const getDocument = await loadPdfJs();
const loadingTask = getDocument({ data: new Uint8Array(data) });
const pdfDocument = await loadingTask.promise;
let destroyed = false;
let renderToken = 0;
let currentUrl: string | null = null;
const releaseUrl = () => {
if (currentUrl) URL.revokeObjectURL(currentUrl);
currentUrl = null;
};
return {
pageCount: pdfDocument.numPages,
render: async (pageNumber) => {
if (destroyed) throw new Error('Die PDF-Vorschau wurde bereits geschlossen.');
const token = ++renderToken;
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));
// 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.');
}
releaseUrl();
currentUrl = imageUrl;
return {
pageNumber: clampedPage,
imageUrl,
width: viewport.width,
height: viewport.height
};
},
destroy: () => {
if (destroyed) return;
destroyed = true;
renderToken += 1;
releaseUrl();
void loadingTask.destroy();
}
};
}
+11 -6
View File
@@ -1,11 +1,16 @@
let pendingZipFiles: File[] = [];
let pendingFiles: File[] = [];
export const queueZipFiles = (files: File[]) => {
pendingZipFiles = [...pendingZipFiles, ...files];
/**
* 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.
*/
export const queuePendingFiles = (files: File[]) => {
pendingFiles = [...pendingFiles, ...files];
};
export const takeQueuedZipFiles = () => {
const files = pendingZipFiles;
pendingZipFiles = [];
export const takePendingFiles = () => {
const files = pendingFiles;
pendingFiles = [];
return files;
};
+7
View File
@@ -12,6 +12,13 @@ export const tools: Tool[] = [
description: 'Versehen Sie Seiten mit Textstempeln wie „Beglaubigt“ oder „Eilt“.',
icon: 'stamp'
},
{
slug: 'pagination',
title: 'Paginieren',
description:
'Versehen Sie alle Seiten mit fortlaufenden Blattnummern und optional einem Aktenzeichen.',
icon: 'list-ordered'
},
{
slug: 'watermark',
title: 'Wasserzeichen',
+12 -10
View File
@@ -9,6 +9,7 @@
Scissors,
Merge,
Stamp,
ListOrdered,
RotateCw,
Info,
FileText
@@ -16,7 +17,7 @@
import { goto } from '$app/navigation';
import FileDropzone from '$lib/components/FileDropzone.svelte';
import * as Card from '$lib/components/ui/card/index';
import { queueZipFiles } from '$lib/pending-files';
import { queuePendingFiles } from '$lib/pending-files';
import { isAcceptedWorkspaceFile, WORKSPACE_ACCEPT_TYPES } from '$lib/zip-selection';
import { tools } from '$lib/tools';
@@ -29,6 +30,7 @@
scissors: Scissors,
merge: Merge,
stamp: Stamp,
'list-ordered': ListOrdered,
'rotate-cw': RotateCw,
info: Info
};
@@ -37,7 +39,7 @@
const workspaceFiles = files.filter(isAcceptedWorkspaceFile);
if (workspaceFiles.length === 0) return;
queueZipFiles(workspaceFiles);
queuePendingFiles(workspaceFiles);
void goto('/tools/bea');
};
@@ -62,12 +64,12 @@
<div class="flex flex-col gap-10 px-4 py-10">
<!-- Hero -->
<div class="flex flex-col items-center gap-2 text-center">
<h1 class="text-[28px] font-bold text-primary-900">Werkzeugkasten für beA</h1>
<p class="max-w-lg text-[15px] font-medium text-primary-700">
<h1 class="text-primary-900 text-[28px] font-bold">Werkzeugkasten für beA</h1>
<p class="text-primary-700 max-w-lg text-[15px] font-medium">
Bereiten Sie ZIP-Archive aus dem besonderen elektronischen Anwaltspostfach (beA) auf — plus
praktische PDF-Werkzeuge.
</p>
<p class="max-w-lg text-[13px] text-primary-700">
<p class="text-primary-700 max-w-lg text-[13px]">
Alle Werkzeuge laufen zu 100 % in Ihrem Browser — keine Dateien werden hochgeladen.
</p>
</div>
@@ -84,7 +86,7 @@
icon={Archive}
class="py-10"
/>
<p class="mt-2 text-center text-[12px] text-primary-600">
<p class="text-primary-600 mt-2 text-center text-[12px]">
Tipp: In beA exportierte Archive enden auf .zip und enthalten Ihre Anlagen als PDF. PDFs
können direkt in Teildokumente aufgeteilt werden.
</p>
@@ -92,12 +94,12 @@
<!-- Tool grid -->
<div class="mx-auto w-full max-w-4xl">
<h2 class="mb-4 text-[18px] font-bold text-primary-900">Weitere PDF-Werkzeuge</h2>
<h2 class="text-primary-900 mb-4 text-[18px] font-bold">Weitere PDF-Werkzeuge</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each tools as tool}
{@const Icon = iconMap[tool.icon] ?? FileText}
<a href="/tools/{tool.slug}" class="block">
<Card.Root class="h-full transition hover:shadow-md cursor-pointer">
<Card.Root class="h-full cursor-pointer transition hover:shadow-md">
<Card.Content class="flex flex-col gap-3 py-6">
<div
class="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10 text-primary"
@@ -105,8 +107,8 @@
<Icon class="h-5 w-5" />
</div>
<div>
<h3 class="text-[15px] font-bold text-primary-900">{tool.title}</h3>
<p class="mt-1 text-[13px] leading-snug text-primary-700">{tool.description}</p>
<h3 class="text-primary-900 text-[15px] font-bold">{tool.title}</h3>
<p class="text-primary-700 mt-1 text-[13px] leading-snug">{tool.description}</p>
</div>
</Card.Content>
</Card.Root>
+3 -3
View File
@@ -14,7 +14,7 @@
} from '$lib/zip-processing';
import { isAcceptedPdfFile } from '$lib/zip-selection';
import { downloadBlob } from '$lib/download';
import { takeQueuedZipFiles } from '$lib/pending-files';
import { takePendingFiles } from '$lib/pending-files';
import * as Card from '$lib/components/ui/card/index';
const THUMBNAIL_WIDTH_STORAGE_KEY = 'thumbnailWidth';
@@ -43,7 +43,7 @@
thumbnailWidthHydrated = true;
const queuedFiles = takeQueuedZipFiles();
const queuedFiles = takePendingFiles();
if (queuedFiles.length > 0) handleFilesSelected(queuedFiles);
});
@@ -201,6 +201,6 @@
{/snippet}
<div>
<h1 class="h1 text-[20px] font-bold text-primary-900">beA-Edit</h1>
<h1 class="h1 text-primary-900 text-[20px] font-bold">beA-Edit</h1>
{@render content()}
</div>
+42 -9
View File
@@ -1,7 +1,9 @@
<script lang="ts">
import FileDropzone from '$lib/components/FileDropzone.svelte';
import Button from '$lib/components/ui/button.svelte';
import { goto } from '$app/navigation';
import { downloadBlob } from '$lib/download';
import { queuePendingFiles } from '$lib/pending-files';
import { mergePdfFiles } from '$lib/pdf-processing';
import { Trash2 } from '@lucide/svelte';
@@ -41,12 +43,34 @@
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;
}
};
</script>
<div class="mx-auto max-w-4xl space-y-6">
<div>
<h1 class="text-2xl font-bold text-primary-900">PDFs zusammenfügen</h1>
<p class="mt-2 text-sm text-primary-700">
<h1 class="text-primary-900 text-2xl font-bold">PDFs zusammenfügen</h1>
<p class="text-primary-700 mt-2 text-sm">
Fügen Sie mehrere PDF-Dateien zu einem einzigen Dokument zusammen.
</p>
</div>
@@ -55,13 +79,22 @@
{#if pdfFiles.length > 0}
<div class="space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-primary-900">
<div class="flex items-center justify-between gap-3">
<h2 class="text-primary-900 text-lg font-semibold">
{pdfFiles.length} Datei{pdfFiles.length === 1 ? '' : 'en'} ausgewählt
</h2>
<Button onclick={mergePdfs} disabled={isProcessing || pdfFiles.length < 2}>
{isProcessing ? 'Wird zusammengefügt...' : 'PDFs zusammenfügen'}
</Button>
<div class="flex flex-wrap items-center justify-end gap-2">
<Button
variant="tertiary"
onclick={mergeAndPaginate}
disabled={isProcessing || pdfFiles.length < 2}
>
Zusammenfügen &amp; paginieren
</Button>
<Button onclick={mergePdfs} disabled={isProcessing || pdfFiles.length < 2}>
{isProcessing ? 'Wird zusammengefügt...' : 'PDFs zusammenfügen'}
</Button>
</div>
</div>
{#if error}
@@ -72,13 +105,13 @@
<div class="space-y-2">
{#each pdfFiles as file, index}
<div class="flex items-center gap-3 rounded-lg border border-primary-200 bg-white p-3">
<div class="border-primary-200 flex items-center gap-3 rounded-lg border bg-white p-3">
<span
class="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary"
>
{index + 1}
</span>
<span class="flex-1 truncate text-sm text-primary-900">{file.name}</span>
<span class="text-primary-900 flex-1 truncate text-sm">{file.name}</span>
<div class="flex gap-1">
<Button
variant="tertiary"
+446
View File
@@ -0,0 +1,446 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { ChevronLeft, ChevronRight, ListOrdered } from '@lucide/svelte';
import FileDropzone from '$lib/components/FileDropzone.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 {
MAX_FONT_SIZE,
MAX_START_NUMBER,
MIN_FONT_SIZE,
MIN_START_NUMBER,
PAGINATION_FORMATS,
buildPaginationLabel,
normalizeStartNumber,
paginatePdf,
type PaginationFormat,
type PaginationTextScope
} from '$lib/pdf-pagination';
import {
PDF_PREVIEW_SCALE,
loadPdfPageRenderer,
type PdfPagePreview,
type PdfPageRenderer
} from '$lib/pdf-thumbnails';
import { takePendingFiles } from '$lib/pending-files';
const PAGE_NUMBER_TEXT_COLOR = '#1a1a1a';
let file = $state<File | null>(null);
let sourceBytes = $state<Uint8Array | null>(null);
let renderer = $state<PdfPageRenderer | null>(null);
let preview = $state<PdfPagePreview | null>(null);
let currentPage = $state(1);
let isLoading = $state(false);
let isProcessing = $state(false);
let error = $state<string | null>(null);
let startNumber = $state(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 labelOptions = $derived({
startNumber: normalizeStartNumber(startNumber),
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 freeTextHint = $derived(
textScope === 'all'
? 'Der Zusatztext erscheint auf jeder Seite.'
: 'Der Zusatztext erscheint nur auf der ersten Seite.'
);
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;
}
};
const handleFilesSelected = (files: File[]) => {
const [nextFile] = files;
if (nextFile) void loadFile(nextFile);
};
const paginate = async () => {
const bytes = sourceBytes;
const currentFile = file;
if (!bytes || !currentFile) return;
isProcessing = true;
error = null;
try {
const output = await paginatePdf(bytes, {
startNumber: normalizeStartNumber(startNumber),
freeText,
textScope,
format: numberFormat,
position,
fontSize
});
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.';
} finally {
isProcessing = false;
}
};
$effect(() => {
const activeRenderer = renderer;
const pageNumber = currentPage;
if (!activeRenderer) return;
let cancelled = false;
void activeRenderer
.render(pageNumber)
.then((result) => {
if (!cancelled) preview = result;
})
.catch((renderError) => {
if (cancelled) return;
console.error('Failed to render pagination preview:', renderError);
preview = null;
});
return () => {
cancelled = true;
};
});
onMount(() => {
const [pending] = takePendingFiles().filter(
(candidate) => candidate.type === 'application/pdf' || /\.pdf$/i.test(candidate.name)
);
if (pending) void loadFile(pending);
});
onDestroy(() => {
loadGeneration += 1;
renderer?.destroy();
});
</script>
<svelte:head>
<title>PDF paginieren — 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">
<ListOrdered class="size-5" aria-hidden="true" />
<span class="text-xs font-extrabold tracking-[0.18em] uppercase">Seitenzählung</span>
</div>
<h1 class="text-2xl font-bold text-foreground">Paginieren</h1>
<p class="mt-2 max-w-2xl text-sm text-primary">
Versehen Sie alle Seiten mit fortlaufenden Blattnummern — auf Wunsch kombiniert mit einem
Aktenzeichen.
</p>
</div>
<FileDropzone
onFilesSelected={handleFilesSelected}
label={file ? 'Andere PDF wählen' : 'PDF zum Paginieren hinzufügen'}
sublabel={isLoading
? 'PDF wird geladen …'
: file
? `${file.name}${pageCount > 0 ? ` · ${pageCount} Seite${pageCount === 1 ? '' : 'n'}` : ''}`
: 'PDF hier ablegen oder auswählen'}
/>
{#if file && renderer}
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(320px,0.9fr)]">
<section
class="flex flex-col gap-5 rounded-2xl border border-border bg-card p-5"
aria-labelledby="pagination-options-title"
>
<div>
<h2 id="pagination-options-title" class="text-lg font-semibold text-foreground">
Nummerierung einstellen
</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.
</p>
</div>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-foreground" for="pagination-start">
Startnummer
</label>
<input
id="pagination-start"
type="number"
min={MIN_START_NUMBER}
max={MAX_START_NUMBER}
value={startNumber}
oninput={handleStartNumberInput}
onblur={syncStartNumberField}
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm"
/>
<p class="text-xs text-muted-foreground">
Seite 1 erhält diese Nummer, danach zählt das Dokument fortlaufend weiter.
</p>
</div>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-foreground" for="pagination-text">
Zusatztext (optional)
</label>
<input
id="pagination-text"
type="text"
bind:value={freeText}
maxlength="60"
placeholder="z. B. 123 Ja 60/26"
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>
</div>
<fieldset class="flex flex-col gap-2">
<legend class="text-sm font-medium text-foreground">Zusatztext anzeigen</legend>
<label class="flex items-center gap-2 text-sm text-foreground">
<input
type="radio"
name="pagination-text-scope"
value="all"
checked={textScope === 'all'}
onchange={() => (textScope = 'all')}
/>
Auf jeder Seite
</label>
<label class="flex items-center gap-2 text-sm text-foreground">
<input
type="radio"
name="pagination-text-scope"
value="first"
checked={textScope === 'first'}
onchange={() => (textScope = 'first')}
/>
Nur auf der ersten Seite
</label>
<p class="text-xs text-muted-foreground">{freeTextHint}</p>
</fieldset>
<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="pagination-format">
Zahlenformat
</label>
<select
id="pagination-format"
bind:value={numberFormat}
class="rounded-lg border border-input bg-background px-3 py-2 text-sm"
>
{#each PAGINATION_FORMATS as format}
<option value={format.value}>{format.label}</option>
{/each}
</select>
</div>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-foreground" for="pagination-position">
Position
</label>
<select
id="pagination-position"
bind:value={position}
class="rounded-lg border border-input bg-background px-3 py-2 text-sm"
>
<option value="top-left">Oben links</option>
<option value="top-center">Oben mittig</option>
<option value="top-right">Oben rechts</option>
<option value="center">Zentriert</option>
<option value="bottom-left">Unten links</option>
<option value="bottom-center">Unten mittig</option>
<option value="bottom-right">Unten rechts</option>
</select>
</div>
</div>
<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="pagination-font-size">
Schriftgröße: {fontSize} pt
</label>
<input
id="pagination-font-size"
type="range"
min={MIN_FONT_SIZE}
max={MAX_FONT_SIZE}
bind:value={fontSize}
class="sm:col-span-2"
/>
</div>
<p
class="rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm text-foreground"
data-testid="pagination-example"
>
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>
</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>
{/if}
<Button size="large" onclick={paginate} disabled={!canPaginate}>
{isProcessing ? 'Wird paginiert …' : 'Paginieren & herunterladen'}
</Button>
</div>
</div>
+278 -282
View File
@@ -1,12 +1,16 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { ChevronLeft, ChevronRight, Stamp } from '@lucide/svelte';
import { degrees, PDFDocument, StandardFonts, type PDFFont } from 'pdf-lib';
import { degrees, PDFDocument, StandardFonts } from 'pdf-lib';
import BatchFileList from '$lib/components/BatchFileList.svelte';
import FileDropzone from '$lib/components/FileDropzone.svelte';
import PdfPageGrid from '$lib/components/PdfPageGrid.svelte';
import Button from '$lib/components/ui/button.svelte';
import { createBatchItem, runBatch, type BatchItem } from '$lib/batch';
import { downloadBlob } from '$lib/download';
import { getPositionCoords, parseHexColor, type OverlayPosition } from '$lib/pdf-overlay';
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 { loadPdfDocument, type PdfDocumentHandle } from '$lib/pdf-thumbnails';
const PRESETS = ['BEGLAUBIGT', 'AUSFERTIGUNG', 'KOPIE', 'EILT', 'ENTWURF', 'ERLEDIGT'] as const;
@@ -24,7 +28,7 @@
height: number;
};
let pdfFile: File | null = $state(null);
let items = $state<BatchItem[]>([]);
let pdfHandle: PdfDocumentHandle | null = $state(null);
let stampInput = $state('BEGLAUBIGT');
let withDate = $state(false);
@@ -40,7 +44,7 @@
let selectedPages = $state(new Set<number>());
let currentPage = $state(1);
let isLoading = $state(false);
let isProcessing = $state(false);
let isRunning = $state(false);
let error = $state<string | null>(null);
let handleGeneration = 0;
@@ -58,53 +62,53 @@
const selectedPreset = $derived(
PRESETS.find((preset) => preset === stampInput.trim().toLocaleUpperCase('de-DE'))
);
const canProcess = $derived(
pdfFile !== null &&
pdfHandle !== null &&
(stampImage !== null || previewText.length > 0) &&
!isLoading &&
!isLoadingImage &&
!isProcessing &&
(applyToAll || selectedPages.size > 0)
const pendingCount = $derived(items.filter((item) => item.status === 'pending').length);
const stampReady = $derived(stampImage !== null || previewText.length > 0);
const pageSelectionMissing = $derived(
items.length === 1 && pdfHandle !== null && !applyToAll && selectedPages.size === 0
);
const canRun = $derived(
!isRunning && pendingCount > 0 && stampReady && !isLoadingImage && !pageSelectionMissing
);
const previewFontSize = $derived(
Math.max(6, Math.min(Number(fontSize) * 0.5, 240 / Math.max(8, previewText.length * 0.62)))
);
const isEncryptedPdfError = (loadError: unknown) => {
const message =
loadError instanceof Error ? `${loadError.name} ${loadError.message}` : String(loadError);
return /password|encrypted|encryption|verschl/i.test(message);
};
const handleFilesSelected = async (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;
const loadErrorMessage = (loadError: unknown) =>
isEncryptedPdfError(loadError)
? 'Dieses PDF ist passwortgeschützt. Entfernen Sie zuerst unter „Passwort entfernen“ den Schutz.'
: 'PDF konnte nicht geladen werden. Prüfen Sie, ob die Datei gültig ist.';
const handleFileSelected = async (files: File[]) => {
const file = files[0];
if (!file) return;
const generation = ++handleGeneration;
pdfHandle?.destroy();
pdfHandle = null;
pdfFile = file;
items = [...items, ...additions];
selectedPages = new Set();
currentPage = 1;
error = null;
isLoading = true;
// Thumbnails and page selection are only available for a single file so
// memory stays flat during multi-file runs.
pdfHandle?.destroy();
pdfHandle = null;
const generation = ++handleGeneration;
if (items.length !== 1) {
isLoading = false;
return;
}
const [item] = items;
isLoading = true;
try {
const handle = await loadPdfDocument(new Uint8Array(await file.arrayBuffer()));
if (generation !== handleGeneration) {
const handle = await loadPdfDocument(new Uint8Array(await item.file.arrayBuffer()));
if (generation !== handleGeneration || items.length !== 1) {
handle.destroy();
return;
}
pdfHandle = handle;
} catch (loadError) {
console.error('Failed to load PDF for stamping:', loadError);
if (generation === handleGeneration) error = loadErrorMessage(loadError);
if (generation === handleGeneration) error = pdfLoadErrorMessage(loadError);
} finally {
if (generation === handleGeneration) isLoading = false;
}
@@ -189,108 +193,16 @@
currentPage = Math.min(pdfHandle.pageCount, currentPage + 1);
};
const getPreviewPositionStyle = (previewPosition: OverlayPosition) => {
switch (previewPosition) {
case 'center':
return 'left: 50%; top: 50%; transform: translate(-50%, -50%);';
case 'top-left':
return 'left: 5%; top: 5%;';
case 'top-center':
return 'left: 50%; top: 5%; transform: translateX(-50%);';
case 'top-right':
return 'right: 5%; top: 5%;';
case 'bottom-left':
return 'bottom: 5%; left: 5%;';
case 'bottom-center':
return 'bottom: 5%; left: 50%; transform: translateX(-50%);';
case 'bottom-right':
return 'bottom: 5%; right: 5%;';
}
};
const getRotatedStampPlacement = (
pageWidth: number,
pageHeight: number,
boxWidth: number,
boxHeight: number,
stampPosition: OverlayPosition,
angle: number
) => {
const radians = (angle * Math.PI) / 180;
const cosine = Math.cos(radians);
const sine = Math.sin(radians);
const rotatedCorners = [
{ x: 0, y: 0 },
{ x: boxWidth * cosine, y: boxWidth * sine },
{ x: -boxHeight * sine, y: boxHeight * cosine },
{
x: boxWidth * cosine - boxHeight * sine,
y: boxWidth * sine + boxHeight * cosine
}
];
const minX = Math.min(...rotatedCorners.map((corner) => corner.x));
const maxX = Math.max(...rotatedCorners.map((corner) => corner.x));
const minY = Math.min(...rotatedCorners.map((corner) => corner.y));
const maxY = Math.max(...rotatedCorners.map((corner) => corner.y));
const boundingCoordinates = getPositionCoords(
pageWidth,
pageHeight,
maxX - minX,
maxY - minY,
stampPosition,
PAGE_MARGIN
);
return {
x: boundingCoordinates.x - minX,
y: boundingCoordinates.y - minY,
cosine,
sine
};
};
const fitStampToPage = (
font: PDFFont,
text: string,
requestedSize: number,
pageWidth: number,
pageHeight: number
) => {
const availableWidth = pageWidth - PAGE_MARGIN * 2;
const availableHeight = pageHeight - PAGE_MARGIN * 2;
let size = requestedSize;
while (size > MIN_EXPORT_FONT_SIZE) {
const width = font.widthOfTextAtSize(text, size) + PADDING * 2;
const height = font.heightAtSize(size) + PADDING * 2;
if (width <= availableWidth && height <= availableHeight) break;
size = Math.max(MIN_EXPORT_FONT_SIZE, size - 0.5);
}
const textWidth = font.widthOfTextAtSize(text, size);
const textHeight = font.heightAtSize(size);
if (textWidth + PADDING * 2 > availableWidth || textHeight + PADDING * 2 > availableHeight) {
throw new Error(
'Der Stempeltext ist für mindestens eine Seite zu lang. Kürzen Sie den Text.'
);
}
return {
size,
textHeight,
boxWidth: textWidth + PADDING * 2,
boxHeight: textHeight + PADDING * 2
};
};
const getImageStampBox = (
image: { width: number; height: number },
pageWidth: number,
pageHeight: number
pageHeight: number,
requestedSize: number,
withFrame: boolean
) => {
const padding = withBorder ? PADDING : 0;
const padding = withFrame ? PADDING : 0;
const aspectRatio = image.width / image.height;
let drawHeight = Number(imageSize);
let drawHeight = requestedSize;
let drawWidth = drawHeight * aspectRatio;
const availableWidth = pageWidth - PAGE_MARGIN * 2 - padding * 2;
@@ -314,46 +226,50 @@
};
};
const applyStamp = async () => {
if (!pdfFile || !pdfHandle || !canProcess) return;
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
// pages of every file.
const stampText = buildStampText(stampInput, withDate, new Date());
const numericFontSize = Number(fontSize);
const numericImageSize = Number(imageSize);
const withFrame = withBorder;
const angle = Number(rotation);
const stampPosition = position;
const color = parseHexColor(stampColor);
const currentStampImage = stampImage;
isProcessing = true;
error = null;
const pdfDoc = await PDFDocument.load(new Uint8Array(await file.arrayBuffer()));
const font = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
const embeddedImage = currentStampImage
? currentStampImage.mimeType === 'image/png'
? await pdfDoc.embedPng(currentStampImage.bytes)
: await pdfDoc.embedJpg(currentStampImage.bytes)
: null;
const pages = pdfDoc.getPages();
const useAllPages = items.length > 1 || applyToAll;
const pageIndexes = useAllPages
? pages.map((_, index) => index)
: Array.from(selectedPages, (pageNumber) => pageNumber - 1);
try {
const stampText = buildStampText(stampInput, withDate, new Date());
const pdfDoc = await PDFDocument.load(new Uint8Array(await pdfFile.arrayBuffer()));
const font = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
const embeddedImage = stampImage
? stampImage.mimeType === 'image/png'
? await pdfDoc.embedPng(stampImage.bytes)
: await pdfDoc.embedJpg(stampImage.bytes)
: null;
const pages = pdfDoc.getPages();
const pageIndexes = applyToAll
? pages.map((_, index) => index)
: Array.from(selectedPages, (pageNumber) => pageNumber - 1);
const color = parseHexColor(stampColor);
for (const pageIndex of pageIndexes) {
const page = pages[pageIndex];
if (!page) continue;
for (const pageIndex of pageIndexes) {
const page = pages[pageIndex];
if (!page) continue;
if (embeddedImage) {
const { width, height } = page.getSize();
const angle = Number(rotation);
const box = embeddedImage
? getImageStampBox(embeddedImage, width, height)
: fitStampToPage(font, stampText, Number(fontSize), width, height);
const placement = getRotatedStampPlacement(
const box = getImageStampBox(embeddedImage, width, height, numericImageSize, withFrame);
const placement = getRotatedBoxPlacement(
width,
height,
box.boxWidth,
box.boxHeight,
position,
angle
stampPosition,
angle,
PAGE_MARGIN
);
if (embeddedImage ? withBorder : true) {
if (withFrame) {
page.drawRectangle({
x: placement.x,
y: placement.y,
@@ -365,44 +281,94 @@
});
}
if (embeddedImage) {
const imageBox = box as { drawWidth: number; drawHeight: number; padding: number };
const offsetX = imageBox.padding;
const offsetY = imageBox.padding;
page.drawImage(embeddedImage, {
x: placement.x + offsetX * placement.cosine - offsetY * placement.sine,
y: placement.y + offsetX * placement.sine + offsetY * placement.cosine,
width: imageBox.drawWidth,
height: imageBox.drawHeight,
rotate: degrees(angle)
});
} else {
const textOffsetX = PADDING;
const textOffsetY = PADDING + (box as { textHeight: number }).textHeight * 0.2;
page.drawText(stampText, {
x: placement.x + textOffsetX * placement.cosine - textOffsetY * placement.sine,
y: placement.y + textOffsetX * placement.sine + textOffsetY * placement.cosine,
size: (box as { size: number }).size,
font,
color,
rotate: degrees(angle)
});
}
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)
});
continue;
}
const resultBytes = await pdfDoc.save();
const baseName = pdfFile.name.replace(/\.pdf$/i, '');
downloadBlob(resultBytes, `${baseName}_stempel.pdf`);
} catch (processingError) {
console.error('Failed to stamp PDF:', processingError);
error = isEncryptedPdfError(processingError)
? loadErrorMessage(processingError)
: processingError instanceof Error && processingError.message.startsWith('Der Stempeltext')
? processingError.message
: 'Der Stempel konnte nicht angewendet werden. Prüfen Sie die PDF-Datei.';
} finally {
isProcessing = false;
drawTextOverlay(page, {
font,
text: stampText,
size: numericFontSize,
color,
position: stampPosition,
margin: PAGE_MARGIN,
padding: PADDING,
framed: true,
rotation: angle,
minSize: MIN_EXPORT_FONT_SIZE,
tooLongMessage:
'Der Stempeltext ist für mindestens eine Seite zu lang. Kürzen Sie den Text.'
});
}
const bytes = await pdfDoc.save();
return { bytes, name: `${file.name.replace(/\.pdf$/i, '')}_stempel.pdf` };
};
const stampErrorMessage = (processingError: unknown) =>
pdfProcessingErrorMessage(
processingError,
'Diese PDF konnte nicht gestempelt 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;
isRunning = true;
try {
await runBatch(queue, processFile, updateItem, {
errorMessage: stampErrorMessage,
// Skip rows removed while they were still waiting in the queue.
shouldProcess: (id) => items.some((item) => item.id === id)
});
} finally {
isRunning = false;
}
};
const retryItem = async (id: string) => {
if (isRunning) return;
const item = items.find((entry) => entry.id === id);
if (!item) return;
isRunning = true;
try {
await runBatch([item], processFile, updateItem, {
errorMessage: stampErrorMessage
});
} finally {
isRunning = false;
}
};
const removeItem = (id: string) => {
const removingPreviewedFile = items.length === 1 && items[0]?.id === id;
items = items.filter((item) => item.id !== id);
if (removingPreviewedFile) {
handleGeneration += 1;
pdfHandle?.destroy();
pdfHandle = null;
selectedPages = new Set();
currentPage = 1;
error = null;
isLoading = false;
}
};
const downloadItem = (item: BatchItem) => {
if (item.resultBytes && item.resultName) downloadBlob(item.resultBytes, item.resultName);
};
onDestroy(() => {
@@ -429,15 +395,20 @@
</div>
<FileDropzone
onFilesSelected={handleFileSelected}
label={pdfHandle ? 'Anderes PDF ablegen' : 'PDF zum Stempeln hinzufügen'}
onFilesSelected={handleFilesSelected}
multiple
label={items.length === 0 ? 'PDFs zum Stempeln hinzufügen' : 'Weitere PDFs hinzufügen'}
sublabel={isLoading
? 'Vorschau wird geladen …'
: (pdfFile?.name ?? 'PDF hier ablegen oder auswählen')}
: items.length === 1
? items[0].file.name
: items.length === 0
? 'PDFs hier ablegen oder auswählen'
: 'Weitere PDFs hinzufügen – bestehende Dateien bleiben erhalten'}
/>
{#if pdfHandle}
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(320px,0.9fr)]">
{#if items.length > 0}
<div class="grid gap-6 {pdfHandle ? 'lg:grid-cols-[minmax(0,1fr)_minmax(320px,0.9fr)]' : ''}">
<section
class="flex flex-col gap-5 rounded-2xl border border-border bg-card p-5"
aria-labelledby="stamp-options-title"
@@ -447,7 +418,8 @@
Stempel einstellen
</h2>
<p class="mt-1 text-xs text-muted-foreground">
Lange Texte werden für kleine Seiten automatisch verkleinert.
Lange Texte werden für kleine Seiten automatisch verkleinert. Die Einstellungen gelten
für alle Dateien der Liste.
</p>
</div>
@@ -590,92 +562,108 @@
</div>
</div>
<label class="flex items-center gap-2 text-sm text-foreground" for="stamp-all-pages">
<input id="stamp-all-pages" type="checkbox" bind:checked={applyToAll} />
Auf alle Seiten anwenden
</label>
{#if items.length > 1}
<p class="text-sm text-primary" role="note">
Der Stempel wird auf alle Seiten aller Dateien angewendet.
</p>
{:else}
<label class="flex items-center gap-2 text-sm text-foreground" for="stamp-all-pages">
<input id="stamp-all-pages" type="checkbox" bind:checked={applyToAll} />
Auf alle Seiten anwenden
</label>
{/if}
{#if isRunning}
<p class="text-xs text-primary" role="note">
Änderungen an den Einstellungen gelten für die noch ausstehenden Dateien.
</p>
{/if}
</section>
<section
class="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5"
aria-labelledby="stamp-preview-title"
>
<div class="flex items-center justify-between gap-3">
<div>
<h2 id="stamp-preview-title" class="text-lg font-semibold text-foreground">Vorschau</h2>
<p class="mt-1 text-xs text-muted-foreground">Annäherung an die Ausgabe im PDF</p>
</div>
<span class="text-xs font-semibold text-primary"
>{currentPage} / {pdfHandle.pageCount}</span
>
</div>
{#if pdfHandle.thumbnails[currentPage - 1]}
{@const currentThumbnail = pdfHandle.thumbnails[currentPage - 1]}
<div
class="relative mx-auto w-full max-w-lg overflow-hidden rounded-lg border border-border bg-white shadow-sm"
>
<img
src={currentThumbnail.imageUrl}
alt="Vorschau von Seite {currentPage}"
class="block w-full"
style="aspect-ratio: {currentThumbnail.width} / {currentThumbnail.height};"
/>
<div class="absolute max-w-[90%]" style={getPreviewPositionStyle(position)}>
{#if stampImage}
<div
data-testid="stamp-preview"
class={withBorder ? 'border-2' : ''}
style="border-color: {stampColor}; padding: {withBorder
? '2px'
: '0'}; transform: rotate({rotation}deg);"
>
<img
src={stampImage.objectUrl}
alt="Stempelbild"
class="block"
style="width: {imageSize * 0.5}px; max-width: 100%;"
/>
</div>
{:else}
<div
data-testid="stamp-preview"
class="border-2 px-2 py-1 font-sans font-extrabold leading-tight whitespace-nowrap"
style="border-color: {stampColor}; color: {stampColor}; font-size: {previewFontSize}px; transform: rotate({rotation}deg);"
>
{previewText}
</div>
{/if}
{#if pdfHandle}
<section
class="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5"
aria-labelledby="stamp-preview-title"
>
<div class="flex items-center justify-between gap-3">
<div>
<h2 id="stamp-preview-title" class="text-lg font-semibold text-foreground">
Vorschau
</h2>
<p class="mt-1 text-xs text-muted-foreground">Annäherung an die Ausgabe im PDF</p>
</div>
<span class="text-xs font-semibold text-primary"
>{currentPage} / {pdfHandle.pageCount}</span
>
</div>
{/if}
{#if pdfHandle.pageCount > 1}
<div class="flex items-center justify-center gap-3">
<Button
variant="tertiary"
class="min-w-0 px-3"
onclick={showPreviousPage}
disabled={currentPage === 1}
aria-label="Vorherige Seite"
{#if pdfHandle.thumbnails[currentPage - 1]}
{@const currentThumbnail = pdfHandle.thumbnails[currentPage - 1]}
<div
class="relative mx-auto w-full max-w-lg overflow-hidden rounded-lg border border-border bg-white shadow-sm"
>
<ChevronLeft aria-hidden="true" />
</Button>
<Button
variant="tertiary"
class="min-w-0 px-3"
onclick={showNextPage}
disabled={currentPage === pdfHandle.pageCount}
aria-label="Nächste Seite"
>
<ChevronRight aria-hidden="true" />
</Button>
</div>
{/if}
</section>
<img
src={currentThumbnail.imageUrl}
alt="Vorschau von Seite {currentPage}"
class="block w-full"
style="aspect-ratio: {currentThumbnail.width} / {currentThumbnail.height};"
/>
<div class="absolute max-w-[90%]" style={getPreviewPositionStyle(position)}>
{#if stampImage}
<div
data-testid="stamp-preview"
class={withBorder ? 'border-2' : ''}
style="border-color: {stampColor}; padding: {withBorder
? '2px'
: '0'}; transform: rotate({rotation}deg);"
>
<img
src={stampImage.objectUrl}
alt="Stempelbild"
class="block"
style="width: {imageSize * 0.5}px; max-width: 100%;"
/>
</div>
{:else}
<div
data-testid="stamp-preview"
class="border-2 px-2 py-1 font-sans leading-tight font-extrabold whitespace-nowrap"
style="border-color: {stampColor}; color: {stampColor}; font-size: {previewFontSize}px; transform: rotate({rotation}deg);"
>
{previewText}
</div>
{/if}
</div>
</div>
{/if}
{#if pdfHandle.pageCount > 1}
<div class="flex items-center justify-center gap-3">
<Button
variant="tertiary"
class="min-w-0 px-3"
onclick={showPreviousPage}
disabled={currentPage === 1}
aria-label="Vorherige Seite"
>
<ChevronLeft aria-hidden="true" />
</Button>
<Button
variant="tertiary"
class="min-w-0 px-3"
onclick={showNextPage}
disabled={currentPage === pdfHandle.pageCount}
aria-label="Nächste Seite"
>
<ChevronRight aria-hidden="true" />
</Button>
</div>
{/if}
</section>
{/if}
</div>
{#if !applyToAll}
{#if pdfHandle && !applyToAll}
<section class="flex flex-col gap-3" aria-labelledby="stamp-pages-title">
<div>
<h2 id="stamp-pages-title" class="text-lg font-semibold text-foreground">
@@ -689,6 +677,14 @@
</section>
{/if}
<BatchFileList
{items}
zipBaseName="stempel"
onDownload={downloadItem}
onRemove={removeItem}
onRetry={retryItem}
/>
<div class="flex flex-col items-end gap-3">
{#if error}
<p
@@ -698,8 +694,8 @@
{error}
</p>
{/if}
<Button size="large" onclick={applyStamp} disabled={!canProcess}>
{isProcessing ? 'Stempel wird angewendet …' : 'Stempel anwenden & herunterladen'}
<Button size="large" onclick={startBatch} disabled={!canRun}>
{isRunning ? 'Stempel wird angewendet …' : 'Stempel anwenden'}
</Button>
</div>
{:else if error}