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',