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

+211
View File
@@ -0,0 +1,211 @@
import { expect, test } from '@playwright/test';
import { unzipSync } from 'fflate';
import { readFile } from 'node:fs/promises';
import { PDFDocument } from 'pdf-lib';
import {
createEncryptedPdf,
createPdf,
dropFiles,
expectPdfDownload,
getPageContentStreamCount,
getPageText,
pdfDropzone,
trackPageErrors
} from './helpers';
const row = (page: import('@playwright/test').Page, fileName: string) =>
page.locator(`[data-exhibit-item="${fileName}"]`);
const labelOf = (page: import('@playwright/test').Page, fileName: string) =>
row(page, fileName).getAttribute('data-exhibit-label');
const renamedOf = (page: import('@playwright/test').Page, fileName: string) =>
row(page, fileName).locator('[data-exhibit-filename]').innerText();
test('exhibits numbers dropped PDFs, follows the list order and renames the downloads', async ({
page
}) => {
const pageErrors = trackPageErrors(page);
const zweiBytes = await createPdf(2, 'Zwei');
const zweiInput = await PDFDocument.load(zweiBytes);
const originalStreamCounts = zweiInput
.getPages()
.map((_, pageIndex) => getPageContentStreamCount(zweiInput, pageIndex));
await page.goto('/tools/exhibits');
await dropFiles(page, pdfDropzone(page), [
{ name: 'eins.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Eins') },
{ name: 'zwei.pdf', mimeType: 'application/pdf', buffer: zweiBytes },
{ name: 'drei.pdf', mimeType: 'application/pdf', buffer: await createPdf(3, 'Drei') }
]);
await expect(page.locator('[data-exhibit-item]')).toHaveCount(3);
expect(await labelOf(page, 'eins.pdf')).toBe('Anlage 1');
await page.getByRole('button', { name: 'Anlage K', exact: true }).click();
await page.locator('#exhibit-start-number').fill('6');
await expect(row(page, 'eins.pdf')).toHaveAttribute('data-exhibit-label', 'Anlage K 6');
await expect(row(page, 'drei.pdf')).toHaveAttribute('data-exhibit-label', 'Anlage K 8');
expect(await renamedOf(page, 'zwei.pdf')).toBe('→ Anlage_K_07_zwei.pdf');
// Reordering the second file to the front renumbers the pending rows.
await page.getByRole('button', { name: 'zwei.pdf nach oben verschieben' }).click();
await expect(row(page, 'zwei.pdf')).toHaveAttribute('data-exhibit-label', 'Anlage K 6');
await expect(row(page, 'eins.pdf')).toHaveAttribute('data-exhibit-label', 'Anlage K 7');
expect(await renamedOf(page, 'zwei.pdf')).toBe('→ Anlage_K_06_zwei.pdf');
// The preview renders only the selected Anlage, page 1, on demand.
await page.getByRole('button', { name: 'Vorschau für zwei.pdf' }).click();
await expect(page.getByTestId('exhibit-preview')).toContainText('Anlage K 6');
await expect(page.getByTestId('exhibit-preview')).toHaveAttribute('data-position', 'top-right');
await expect(page.getByText('Seite 1 von 2')).toBeVisible();
await page.getByRole('button', { name: 'Anlagen erstellen', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toHaveCount(3, { timeout: 30_000 });
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'zwei.pdf herunterladen' }).click();
const outputBytes = await expectPdfDownload(await downloadPromise, /^Anlage_K_06_zwei\.pdf$/);
const output = await PDFDocument.load(outputBytes);
expect(output.getPageCount()).toBe(2);
expect(getPageText(output, 0)).toContain('Anlage K 6');
// Only the first page is stamped by default; page 2 keeps its stream count.
expect(getPageContentStreamCount(output, 1)).toBe(originalStreamCounts[1]);
expect(getPageText(output, 1)).not.toContain('Anlage K 6');
expect(pageErrors).toEqual([]);
});
test('exhibits switches to three digits from 98 and keeps labels unpadded', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/exhibits');
await dropFiles(page, pdfDropzone(page), [
{ name: 'a.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'A') },
{ name: 'b.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'B') },
{ name: 'c.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'C') }
]);
await page.locator('#exhibit-start-number').fill('98');
await expect(row(page, 'a.pdf')).toHaveAttribute('data-exhibit-label', 'Anlage 98');
expect(await renamedOf(page, 'a.pdf')).toBe('→ Anlage_098_a.pdf');
expect(await renamedOf(page, 'c.pdf')).toBe('→ Anlage_100_c.pdf');
await page.getByRole('button', { name: 'Anlagen erstellen', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toHaveCount(3, { timeout: 30_000 });
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'a.pdf herunterladen' }).click();
const first = await expectPdfDownload(await downloadPromise, /^Anlage_098_a\.pdf$/);
expect(getPageText(await PDFDocument.load(first), 0)).toContain('Anlage 98');
const secondPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'c.pdf herunterladen' }).click();
await expectPdfDownload(await secondPromise, /^Anlage_100_c\.pdf$/);
expect(pageErrors).toEqual([]);
});
test('exhibits keeps the original names and honours the every-page option', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/exhibits');
await dropFiles(page, pdfDropzone(page), [
{ name: 'original.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Original') }
]);
await page.getByRole('checkbox', { name: 'Dateien umbenennen' }).uncheck();
await expect(row(page, 'original.pdf').locator('[data-exhibit-filename]')).toHaveCount(0);
await page.getByRole('checkbox', { name: 'Auf jeder Seite stempeln' }).check();
await page.getByRole('button', { name: 'Anlagen erstellen', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toBeVisible({ timeout: 20_000 });
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'original.pdf herunterladen' }).click();
const outputBytes = await expectPdfDownload(await downloadPromise, /^original\.pdf$/);
const output = await PDFDocument.load(outputBytes);
expect(getPageText(output, 0)).toContain('Anlage 1');
expect(getPageText(output, 1)).toContain('Anlage 1');
expect(pageErrors).toEqual([]);
});
test('exhibits freezes the number of finished rows and says so', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/exhibits');
await dropFiles(page, pdfDropzone(page), [
{ name: 'erst.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Erst') },
{ name: 'dann.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Dann') }
]);
await page.getByRole('button', { name: 'Anlagen erstellen', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toHaveCount(2, { timeout: 20_000 });
await expect(row(page, 'erst.pdf')).toHaveAttribute('data-exhibit-label', 'Anlage 1');
await page.getByRole('button', { name: 'dann.pdf nach oben verschieben' }).click();
await expect(row(page, 'dann.pdf')).toHaveAttribute('data-exhibit-label', 'Anlage 2');
await expect(row(page, 'erst.pdf')).toHaveAttribute('data-exhibit-label', 'Anlage 1');
expect(await renamedOf(page, 'dann.pdf')).toBe('→ Anlage_02_dann.pdf');
expect(pageErrors).toEqual([]);
});
test('exhibits blocks an empty designation and recovers from a broken file', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/exhibits');
await dropFiles(page, pdfDropzone(page), [
{ name: 'gut.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Gut') },
{ name: 'kaputt.pdf', mimeType: 'application/pdf', buffer: Buffer.from('kein PDF') }
]);
await page.locator('#exhibit-prefix').fill(' ');
await expect(page.getByText('Bitte geben Sie eine Bezeichnung an')).toBeVisible();
await expect(page.getByRole('button', { name: 'Anlagen erstellen', exact: true })).toBeDisabled();
await page.locator('#exhibit-prefix').fill('Anlage B');
await page.getByRole('button', { name: 'Anlagen erstellen', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toHaveCount(1, { timeout: 20_000 });
await expect(row(page, 'kaputt.pdf')).toContainText('Fehler');
await expect(row(page, 'gut.pdf')).toHaveAttribute('data-exhibit-label', 'Anlage B 1');
expect(pageErrors).toEqual([]);
});
test('exhibits bundles all finished Anlagen in one ZIP', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/exhibits');
await dropFiles(page, pdfDropzone(page), [
{ name: 'eins.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Eins') },
{ name: 'zwei.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Zwei') }
]);
await page.getByRole('button', { name: 'Anlagen erstellen', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toHaveCount(2, { timeout: 20_000 });
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Alle herunterladen' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/^anlagen_\d{4}-\d{2}-\d{2}\.zip$/);
const zipPath = await download.path();
expect(zipPath).not.toBeNull();
expect(Object.keys(unzipSync(new Uint8Array(await readFile(zipPath!)))).sort()).toEqual([
'Anlage_01_eins.pdf',
'Anlage_02_zwei.pdf'
]);
expect(pageErrors).toEqual([]);
});
test('exhibits reports encrypted PDFs as row errors and keeps going', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/exhibits');
await dropFiles(page, pdfDropzone(page), [
{
name: 'gesperrt.pdf',
mimeType: 'application/pdf',
buffer: await createEncryptedPdf(1, 'Gesperrt', 'geheim')
},
{ name: 'offen.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Offen') }
]);
await page.getByRole('button', { name: 'Anlagen erstellen', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toHaveCount(1, { timeout: 20_000 });
await expect(row(page, 'gesperrt.pdf')).toContainText('passwortgeschützt');
expect(pageErrors).toEqual([]);
});
+107 -7
View File
@@ -123,19 +123,88 @@ export const getPageContentText = (document: PDFDocument, pageIndex: number) =>
.join('\n');
};
const PDF_STRING_ESCAPES: Record<string, string> = {
n: '\n',
r: '\r',
t: '\t',
b: '\b',
f: '\f',
'(': '(',
')': ')',
'\\': '\\'
};
/** Decodes a single PDF string token (`<hex>` or `(literal)`). */
const decodePdfString = (token: string): string => {
if (token.startsWith('<')) {
return Buffer.from(token.slice(1, -1).replace(/\s/g, ''), 'hex').toString('latin1');
}
const raw = token.slice(1, -1);
let decoded = '';
for (let index = 0; index < raw.length; index += 1) {
const char = raw[index];
if (char !== '\\') {
decoded += char;
continue;
}
const escaped = raw[index + 1];
index += 1;
if (escaped === undefined) break;
if (escaped >= '0' && escaped <= '7') {
let octal = escaped;
while (octal.length < 3 && raw[index + 1] >= '0' && raw[index + 1] <= '7') {
octal += raw[(index += 1)];
}
decoded += String.fromCharCode(Number.parseInt(octal, 8));
continue;
}
decoded += PDF_STRING_ESCAPES[escaped] ?? escaped;
}
return decoded;
};
const PDF_STRING_TOKEN = String.raw`<[0-9a-fA-F\s]*>|\((?:\\.|[^()\\])*\)`;
const TEXT_SHOWING_PATTERN = new RegExp(
String.raw`\[((?:[^\[\]\\]|\\.)*)\]\s*TJ|(${PDF_STRING_TOKEN})\s*(?:Tj|'|")`,
'g'
);
const STRING_TOKEN_PATTERN = new RegExp(PDF_STRING_TOKEN, 'g');
/**
* Decoded operands of all `Tj` operators. pdf-lib writes drawn text as hex
* strings (`<426C2E2031> Tj`), so the raw content stream is not readable.
* Decoded operands of every text-showing operator (`Tj`, `TJ`, `'`, `"`).
* pdf-lib writes drawn text as hex strings (`<426C2E2031> Tj`), so the raw
* content stream is not readable without this decoding.
*
* Limitations: other text operators (`Td`, `Tf`, …) and non-text strings are
* ignored. A page that shows text through an operator this helper does not know
* returns `[]`, so `not.toContain(...)` assertions on it prove nothing — assert
* on a positive match instead.
*/
export const getPageTextValues = (document: PDFDocument, pageIndex: number) => {
const content = getPageContentText(document, pageIndex);
const values: string[] = [];
for (const match of content.matchAll(/<([0-9a-fA-F]+)>\s*Tj/g)) {
values.push(Buffer.from(match[1], 'hex').toString('latin1'));
}
for (const match of content.matchAll(/\(((?:\\.|[^()\\])*)\)\s*Tj/g)) {
values.push(match[1]);
for (const match of content.matchAll(TEXT_SHOWING_PATTERN)) {
const [, arrayBody, singleToken] = match;
if (singleToken !== undefined) {
values.push(decodePdfString(singleToken));
continue;
}
for (const token of arrayBody.matchAll(STRING_TOKEN_PATTERN)) {
values.push(decodePdfString(token[0]));
}
}
return values;
@@ -144,6 +213,37 @@ export const getPageTextValues = (document: PDFDocument, pageIndex: number) => {
export const getPageText = (document: PDFDocument, pageIndex: number) =>
getPageTextValues(document, pageIndex).join('\n');
export type PageTextMatrix = { a: number; b: number; c: number; d: number; x: number; y: number };
/**
* Translation and rotation of every text matrix (`Tm`) in a page's content
* stream, in drawing order. Lets tests assert on the drawing geometry without
* hard-coding the hex-encoded text operands.
*/
export const getPageTextMatrices = (document: PDFDocument, pageIndex: number): PageTextMatrix[] => {
const content = getPageContentText(document, pageIndex);
const matrices: PageTextMatrix[] = [];
for (const match of content.matchAll(
/([-\d.]+) ([-\d.]+) ([-\d.]+) ([-\d.]+) ([-\d.]+) ([-\d.]+) Tm/g
)) {
const [a, b, c, d, x, y] = match.slice(1).map(Number);
matrices.push({ a, b, c, d, x, y });
}
if (matrices.length === 0) throw new Error(`Page ${pageIndex + 1} contains no text matrix.`);
return matrices;
};
/** First text matrix of a page (the only one in a single-mark document). */
export const getPageTextMatrix = (document: PDFDocument, pageIndex: number): PageTextMatrix =>
getPageTextMatrices(document, pageIndex)[0];
/** Angle of a text matrix in degrees, normalised to `0 … 359`. */
export const getPageTextAngle = (matrix: PageTextMatrix) =>
((Math.atan2(matrix.b, matrix.a) * 180) / Math.PI + 360) % 360;
export const createEncryptedPdf = async (pageCount: number, label: string, password: string) => {
const document = await EncryptablePdfDocument.load(await createPdf(pageCount, label));
document.encrypt({
+46 -25
View File
@@ -1,5 +1,6 @@
import { expect, test, type Page } from '@playwright/test';
import { PDFDocument } from 'pdf-lib';
import { PAGE_NUMBER_MARGIN } from '../../src/lib/pdf-pagination';
import {
createEncryptedPdf,
createPdf,
@@ -8,10 +9,21 @@ import {
expectPdfDownload,
getPageContentText,
getPageText,
getPageTextAngle,
getPageTextMatrices,
getPageTextValues,
pdfDropzone,
trackPageErrors
} from './helpers';
/**
* Number of text operands equal to `label` on a page. The source documents carry
* their own text, so this counts the stamped sheet label instead of comparing
* the whole page.
*/
const countLabel = (document: PDFDocument, pageIndex: number, label: string) =>
getPageTextValues(document, pageIndex).filter((value) => value === label).length;
const downloadPaginated = async (page: Page, fileName: RegExp) => {
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Paginieren & herunterladen' }).click();
@@ -41,9 +53,10 @@ test('pagination stamps every page with an Aktenzeichen and the configured start
const bytes = await downloadPaginated(page, /^akte_paginiert\.pdf$/);
const output = await PDFDocument.load(bytes);
expect(output.getPageCount()).toBe(3);
expect(getPageText(output, 0)).toContain('123 Ja 60/26, Bl. 26');
expect(getPageText(output, 1)).toContain('123 Ja 60/26, Bl. 27');
expect(getPageText(output, 2)).toContain('123 Ja 60/26, Bl. 28');
// Exactly one label per page: a duplicated draw would still pass `toContain`.
expect(countLabel(output, 0, '123 Ja 60/26, Bl. 26')).toBe(1);
expect(countLabel(output, 1, '123 Ja 60/26, Bl. 27')).toBe(1);
expect(countLabel(output, 2, '123 Ja 60/26, Bl. 28')).toBe(1);
expect(pageErrors).toEqual([]);
});
@@ -111,30 +124,38 @@ test('pagination keeps the page number upright on rotated scans', async ({ page
const output = await PDFDocument.load(bytes);
expect(output.getPageCount()).toBe(2);
// Page 1 is stored with /Rotate 90: the text is counter-rotated so it reads
// `createPreRotatedPdf` stores 420×594 pages and puts /Rotate 90 on page 1, so
// the page is displayed as 594×420. The label is counter-rotated so it reads
// horizontally on screen instead of sideways.
const rotatedContent = getPageContentText(output, 0);
expect(getPageText(output, 0)).toContain('Bl. 1');
const rotatedMatrix =
/([-\d.]+) ([-\d.]+) ([-\d.]+) ([-\d.]+) ([-\d.]+) ([-\d.]+) Tm\n<426C2E2031> Tj/.exec(
rotatedContent
);
expect(rotatedMatrix).not.toBeNull();
const [a, b, c, d, x, y] = rotatedMatrix!.slice(1).map(Number);
// 90° counter-clockwise in user space cancels the viewer's page rotation.
expect(Math.abs(a)).toBeLessThan(0.001);
expect(b).toBeCloseTo(1, 3);
expect(c).toBeCloseTo(-1, 3);
expect(Math.abs(d)).toBeLessThan(0.001);
// Visual bottom center of the 594×420 display: 28 pt margin, centered.
expect(x).toBeGreaterThan(384);
expect(x).toBeLessThan(396);
expect(y).toBeGreaterThan(280);
expect(y).toBeLessThan(292);
// The stamp is the last text drawn on the page.
const rotated = getPageTextMatrices(output, 0).at(-1)!;
expect(Math.round(getPageTextAngle(rotated))).toBe(90);
expect(countLabel(output, 0, 'Bl. 1')).toBe(1);
// Visual bottom centre maps back into user space as x = stored width − margin.
expect(rotated.x).toBeCloseTo(420 - PAGE_NUMBER_MARGIN, -1);
// …and stays horizontally centred on the displayed width.
expect(Math.abs(rotated.y - 594 / 2)).toBeLessThan(15);
const plainContent = getPageContentText(output, 1);
expect(getPageText(output, 1)).toContain('Bl. 2');
expect(plainContent).toMatch(/1 0 0 1 [-\d.]+ [-\d.]+ Tm\n<426C2E2032> Tj/);
const plain = getPageTextMatrices(output, 1).at(-1)!;
expect(Math.round(getPageTextAngle(plain))).toBe(0);
expect(countLabel(output, 1, 'Bl. 2')).toBe(1);
expect(pageErrors).toEqual([]);
});
test('pagination offers the "Seite" format and applies it to every page', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/pagination');
await dropFiles(page, pdfDropzone(page), [
{ name: 'formate.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Formate') }
]);
await page.locator('#pagination-format').selectOption('seite');
await expect(page.getByTestId('pagination-example')).toHaveText('Beispiel: Seite 1');
const bytes = await downloadPaginated(page, /^formate_paginiert\.pdf$/);
const output = await PDFDocument.load(bytes);
expect(countLabel(output, 0, 'Seite 1')).toBe(1);
expect(countLabel(output, 1, 'Seite 2')).toBe(1);
expect(pageErrors).toEqual([]);
});
+16
View File
@@ -0,0 +1,16 @@
import { expect, test } from '@playwright/test';
import { tools } from '../../src/lib/tools';
/**
* The tool registry, its icon map, the route files and the page headings all
* live in different files. This catches the case where one of them drifts and a
* registered tool 404s or renders under a different title.
*/
for (const tool of tools) {
test(`the registry entry "${tool.title}" renders at /tools/${tool.slug}`, async ({ page }) => {
const response = await page.goto(`/tools/${tool.slug}`);
expect(response?.status()).toBe(200);
await expect(page.getByRole('heading', { level: 1, name: tool.title })).toBeVisible();
});
}
+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);
});