implements page-numbers-plan
This commit is contained in:
1 parent
39da8dac4d
commit
b5279434b2
19 files changed
+1823
-339
No files matched your search
+7
-3
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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%;';
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user