refactoring, numbering plan

This commit is contained in:
rehlert committed 2026-10-28 16:09:53 +01:00
1 parent b5279434b2
commit 3b04039aa9
47 files changed
+3485 -953

No files matched your search

+83
View File
@@ -0,0 +1,83 @@
import { expect, test } from '@playwright/test';
import {
DEFAULT_EXHIBIT_PREFIX,
MAX_EXHIBIT_PREFIX_LENGTH,
MAX_EXHIBIT_START_NUMBER,
MIN_EXHIBIT_START_NUMBER,
buildExhibitFileName,
buildExhibitLabel,
buildExhibitPrefixSlug,
getExhibitNumberWidth,
normalizeExhibitPrefix,
normalizeExhibitStartNumber
} from '../../src/lib/exhibit-naming';
test('the prefix is trimmed and capped', () => {
expect(normalizeExhibitPrefix(' Anlage K ')).toBe('Anlage K');
expect(normalizeExhibitPrefix('')).toBe('');
expect(normalizeExhibitPrefix('A'.repeat(MAX_EXHIBIT_PREFIX_LENGTH + 10))).toHaveLength(
MAX_EXHIBIT_PREFIX_LENGTH
);
});
test('labels use one space and never pad the number', () => {
expect(buildExhibitLabel('Anlage K', 6)).toBe('Anlage K 6');
expect(buildExhibitLabel(' Anlage ', 1)).toBe('Anlage 1');
expect(buildExhibitLabel('Anlage', 98)).toBe('Anlage 98');
});
test('the padding width follows the highest assigned number', () => {
expect(getExhibitNumberWidth(9)).toBe(2);
expect(getExhibitNumberWidth(99)).toBe(2);
expect(getExhibitNumberWidth(100)).toBe(3);
expect(getExhibitNumberWidth(1000)).toBe(4);
});
test('file names prefix the number and keep the original base name', () => {
expect(
buildExhibitFileName({
prefix: 'Anlage K',
number: 6,
highestNumber: 8,
originalName: 'Klageschrift.pdf'
})
).toBe('Anlage_K_06_Klageschrift.pdf');
expect(
buildExhibitFileName({
prefix: 'Anlage K',
number: 98,
highestNumber: 100,
originalName: 'Anlage.PDF'
})
).toBe('Anlage_K_098_Anlage.pdf');
});
test('unsafe characters never reach the file name', () => {
expect(
buildExhibitFileName({
prefix: 'Anlage K/1',
number: 1,
highestNumber: 1,
originalName: 'a:b*c?.pdf'
})
).toBe('Anlage_K1_01_abc.pdf');
});
test('an empty prefix falls back to the default slug', () => {
expect(buildExhibitPrefixSlug(' ')).toBe(DEFAULT_EXHIBIT_PREFIX);
expect(
buildExhibitFileName({ prefix: '', number: 3, highestNumber: 3, originalName: 'x.pdf' })
).toBe('Anlage_03_x.pdf');
});
test('the start number is clamped and survives an empty field', () => {
expect(normalizeExhibitStartNumber(1)).toBe(1);
expect(normalizeExhibitStartNumber(0)).toBe(MIN_EXHIBIT_START_NUMBER);
expect(normalizeExhibitStartNumber(-5)).toBe(MIN_EXHIBIT_START_NUMBER);
expect(normalizeExhibitStartNumber(6.6)).toBe(7);
expect(normalizeExhibitStartNumber(undefined)).toBe(1);
expect(normalizeExhibitStartNumber(null)).toBe(1);
expect(normalizeExhibitStartNumber('')).toBe(1);
expect(normalizeExhibitStartNumber(1_000_000)).toBe(MAX_EXHIBIT_START_NUMBER);
});
+40
View File
@@ -0,0 +1,40 @@
import { expect, test } from '@playwright/test';
import {
ENCRYPTED_PDF_MESSAGE,
TextOverlayError,
classifyPdfError,
pdfLoadErrorMessage,
pdfProcessingErrorMessage
} from '../../src/lib/pdf-errors';
const encryptedError = () =>
new Error(
'Input document to `PDFDocument.load` is encrypted. You can use `PDFDocument.load(..., { ignoreEncryption: true })`'
);
test('overlay failures keep their own copy', () => {
const error = new TextOverlayError('Der Text ist zu lang.');
expect(classifyPdfError(error)).toBe('text-overlay');
expect(pdfProcessingErrorMessage(error, 'Fallback')).toBe('Der Text ist zu lang.');
expect(pdfLoadErrorMessage(error)).toBe('Der Text ist zu lang.');
});
test('encrypted PDFs map to the shared password hint', () => {
expect(classifyPdfError(encryptedError())).toBe('encrypted');
expect(pdfProcessingErrorMessage(encryptedError(), 'Fallback')).toBe(ENCRYPTED_PDF_MESSAGE);
expect(pdfLoadErrorMessage(encryptedError())).toBe(ENCRYPTED_PDF_MESSAGE);
});
test('unknown failures fall back to the caller copy', () => {
const error = new Error('Invalid PDF structure');
expect(classifyPdfError(error)).toBe('unknown');
expect(pdfProcessingErrorMessage(error, 'Fehler beim Drehen der Seiten.')).toBe(
'Fehler beim Drehen der Seiten.'
);
expect(pdfLoadErrorMessage(error, 'Eigene Meldung.')).toBe('Eigene Meldung.');
expect(pdfLoadErrorMessage(error)).toContain('PDF konnte nicht geladen werden');
// Tool-specific copy must never be overridden by the classification.
expect(pdfProcessingErrorMessage('boom', 'Eigene Meldung.')).toBe('Eigene Meldung.');
});
+131
View File
@@ -0,0 +1,131 @@
import { expect, test } from '@playwright/test';
import { PDFDocument } from 'pdf-lib';
import { TextOverlayError } from '../../src/lib/pdf-errors';
import type { OverlayPosition } from '../../src/lib/pdf-overlay';
import {
MAX_FREE_TEXT_LENGTH,
PAGINATION_FORMATS,
buildPaginationLabel,
normalizeFreeText,
normalizeStartNumber,
paginatePdf,
type PaginationFormat,
type PaginationOptions,
type PaginationTextScope
} from '../../src/lib/pdf-pagination';
import { getPageTextValues } from '../e2e/helpers';
const labelOptions = (
overrides: Partial<{
startNumber: number;
freeText: string;
textScope: PaginationTextScope;
format: PaginationFormat;
}> = {}
) => ({
startNumber: 1,
freeText: '',
textScope: 'all' as const,
format: 'blatt' as const,
...overrides
});
const paginationOptions = (overrides: Partial<PaginationOptions> = {}): PaginationOptions => ({
startNumber: 1,
freeText: '',
textScope: 'all',
format: 'blatt',
position: 'bottom-center' as OverlayPosition,
fontSize: 11,
...overrides
});
/** Blank pages, so the stamped sheet number is the only text in the output. */
const createPdf = async (pageCount: number, size: [number, number] = [420, 594]) => {
const document = await PDFDocument.create();
for (let index = 0; index < pageCount; index += 1) document.addPage(size);
return new Uint8Array(await document.save());
};
test('every format is offered with a rendered example label', () => {
expect(PAGINATION_FORMATS.map((format) => format.value)).toEqual([
'blatt',
'seite',
's',
'nummer'
]);
expect(PAGINATION_FORMATS.map((format) => format.label)).toEqual([
'Bl. 1',
'Seite 1',
'S. 1',
'1'
]);
});
test('label combines the free text with the ascending sheet number', () => {
expect(buildPaginationLabel(0, labelOptions({ startNumber: 26 }))).toBe('Bl. 26');
expect(buildPaginationLabel(2, labelOptions({ startNumber: 26 }))).toBe('Bl. 28');
expect(buildPaginationLabel(0, labelOptions({ freeText: ' 123 Ja 60/26 ' }))).toBe(
'123 Ja 60/26, Bl. 1'
);
expect(buildPaginationLabel(0, labelOptions({ format: 'seite', startNumber: 6 }))).toBe(
'Seite 6'
);
expect(buildPaginationLabel(0, labelOptions({ format: 's', startNumber: 6 }))).toBe('S. 6');
expect(buildPaginationLabel(0, labelOptions({ format: 'nummer', startNumber: 6 }))).toBe('6');
// No leading zeros — a start number of 6 is not "06".
expect(buildPaginationLabel(0, labelOptions({ startNumber: 6 }))).toBe('Bl. 6');
});
test('the free text is dropped on later pages for the first-page scope', () => {
const options = labelOptions({ freeText: 'AZ 7/26', textScope: 'first', format: 's' });
expect(buildPaginationLabel(0, options)).toBe('AZ 7/26, S. 1');
expect(buildPaginationLabel(1, options)).toBe('S. 2');
});
test('start number and free text are normalised at the boundary', () => {
expect(normalizeStartNumber(0)).toBe(1);
expect(normalizeStartNumber(-5)).toBe(1);
expect(normalizeStartNumber(2.6)).toBe(3);
expect(normalizeStartNumber(Number.NaN)).toBe(1);
expect(normalizeStartNumber(1_000_000)).toBe(99_999);
expect(normalizeFreeText(' 123 Ja 60/26 ')).toBe('123 Ja 60/26');
expect(normalizeFreeText('x'.repeat(90))).toHaveLength(MAX_FREE_TEXT_LENGTH);
});
test('paginatePdf stamps each page exactly once', async () => {
const output = await paginatePdf(
await createPdf(3),
paginationOptions({ startNumber: 26, freeText: '123 Ja 60/26' })
);
const document = await PDFDocument.load(output);
expect(document.getPageCount()).toBe(3);
expect(getPageTextValues(document, 0)).toEqual(['123 Ja 60/26, Bl. 26']);
expect(getPageTextValues(document, 1)).toEqual(['123 Ja 60/26, Bl. 27']);
expect(getPageTextValues(document, 2)).toEqual(['123 Ja 60/26, Bl. 28']);
});
test('paginatePdf honours the first-page-only scope', async () => {
const output = await paginatePdf(
await createPdf(2),
paginationOptions({ freeText: 'AZ 7/26', textScope: 'first', format: 'nummer' })
);
const document = await PDFDocument.load(output);
expect(getPageTextValues(document, 0)).toEqual(['AZ 7/26, 1']);
expect(getPageTextValues(document, 1)).toEqual(['2']);
});
test('a label that cannot fit at the minimum font size fails loudly', async () => {
await expect(
paginatePdf(
await createPdf(1, [120, 160]),
paginationOptions({ freeText: 'x'.repeat(MAX_FREE_TEXT_LENGTH), fontSize: 18 })
)
).rejects.toBeInstanceOf(TextOverlayError);
});
+133
View File
@@ -0,0 +1,133 @@
import { expect, test } from '@playwright/test';
import { degrees, PDFDocument, StandardFonts, rgb } from 'pdf-lib';
import { TextOverlayError } from '../../src/lib/pdf-errors';
import {
TEXT_OVERLAY_MARGIN,
drawTextOverlay,
getRotatedBoxPlacement
} from '../../src/lib/pdf-text-overlay';
import { getPageText, getPageTextAngle, getPageTextMatrix } from '../e2e/helpers';
const PAGE_WIDTH = 420;
const PAGE_HEIGHT = 594;
const MARGIN = 28;
/**
* Maps a point from default user space to the displayed page. Viewers rotate the
* content clockwise by `/Rotate`, so this is the forward transformation.
*/
const toVisualPoint = (
point: { x: number; y: number },
rotation: number,
width: number,
height: number
) => {
switch (rotation) {
case 90:
return { x: point.y, y: width - point.x };
case 180:
return { x: width - point.x, y: height - point.y };
case 270:
return { x: height - point.y, y: point.x };
default:
return point;
}
};
const createPageWithMark = async (rotation: number) => {
const document = await PDFDocument.create();
const font = await document.embedFont(StandardFonts.Helvetica);
const page = document.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
page.setRotation(degrees(rotation));
drawTextOverlay(page, {
font,
text: 'Bl. 1',
size: 11,
color: rgb(0.1, 0.1, 0.1),
position: 'bottom-center',
margin: MARGIN,
compensatePageRotation: true
});
return PDFDocument.load(await document.save());
};
for (const rotation of [0, 90, 180, 270]) {
test(`bottom-center mark stays at the visual bottom on a /Rotate ${rotation} page`, async () => {
const output = await createPageWithMark(rotation);
const matrix = getPageTextMatrix(output, 0);
const visualWidth = rotation % 180 === 0 ? PAGE_WIDTH : PAGE_HEIGHT;
const origin = toVisualPoint({ x: matrix.x, y: matrix.y }, rotation, PAGE_WIDTH, PAGE_HEIGHT);
// Horizontally centred on the displayed page.
expect(Math.abs(origin.x - visualWidth / 2)).toBeLessThan(30);
// Close to the bottom margin (the baseline sits a few points above it).
expect(origin.y).toBeGreaterThan(MARGIN - 2);
expect(origin.y).toBeLessThan(MARGIN + 20);
// The glyphs read horizontally: the drawn angle cancels the page rotation.
expect(Math.round(getPageTextAngle(matrix))).toBe(rotation);
expect(getPageText(output, 0)).toBe('Bl. 1');
});
}
test('a mark that does not fit raises a TextOverlayError instead of shrinking forever', async () => {
const document = await PDFDocument.create();
const font = await document.embedFont(StandardFonts.Helvetica);
const page = document.addPage([120, 160]);
expect(() =>
drawTextOverlay(page, {
font,
text: 'Ein sehr langer Stempeltext, der nicht auf die Seite passt',
size: 18,
color: rgb(0, 0, 0),
position: 'bottom-center',
margin: MARGIN,
framed: true
})
).toThrow(TextOverlayError);
});
test('characters the standard font cannot encode are reported as such', async () => {
const document = await PDFDocument.create();
const font = await document.embedFont(StandardFonts.Helvetica);
const page = document.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
let thrown: unknown;
try {
drawTextOverlay(page, {
font,
text: 'Beschluss \u{1F600}',
size: 11,
color: rgb(0, 0, 0),
position: 'top-right',
margin: MARGIN
});
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(TextOverlayError);
expect((thrown as Error).message).toContain('nicht dargestellt werden');
});
test('a rotated mark keeps its bounding box inside the page', async () => {
const boxWidth = 200;
const boxHeight = 40;
for (const angle of [0, 45, 90, 135]) {
const placement = getRotatedBoxPlacement(
PAGE_WIDTH,
PAGE_HEIGHT,
boxWidth,
boxHeight,
'bottom-center',
angle,
TEXT_OVERLAY_MARGIN
);
expect(placement.x).toBeGreaterThanOrEqual(TEXT_OVERLAY_MARGIN - 0.001);
expect(placement.y).toBeGreaterThanOrEqual(TEXT_OVERLAY_MARGIN - 0.001);
expect(placement).not.toHaveProperty('cosine');
}
});
+29
View File
@@ -0,0 +1,29 @@
import { expect, test } from '@playwright/test';
import {
clearPendingFiles,
queuePendingFiles,
takePendingFiles
} from '../../src/lib/pending-files';
const pdf = () => new File(['%PDF-1.7'], 'akte.pdf', { type: 'application/pdf' });
const zip = () => new File(['PK'], 'archiv.zip', { type: 'application/zip' });
test.beforeEach(() => clearPendingFiles());
test('a target only receives the files queued for it', () => {
queuePendingFiles([zip()], 'bea');
queuePendingFiles([pdf()], 'pagination');
const paginationFiles = takePendingFiles('pagination');
expect(paginationFiles.map((file) => file.name)).toEqual(['akte.pdf']);
// The beA handoff must survive a pagination visit instead of being dropped.
expect(takePendingFiles('bea').map((file) => file.name)).toEqual(['archiv.zip']);
});
test('taking a target clears only that target', () => {
queuePendingFiles([pdf(), pdf()], 'pagination');
expect(takePendingFiles('pagination')).toHaveLength(2);
expect(takePendingFiles('pagination')).toHaveLength(0);
});