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

+21
View File
@@ -123,6 +123,27 @@ export const getPageContentText = (document: PDFDocument, pageIndex: number) =>
.join('\n');
};
/**
* Decoded operands of all `Tj` operators. pdf-lib writes drawn text as hex
* strings (`<426C2E2031> Tj`), so the raw content stream is not readable.
*/
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]);
}
return values;
};
export const getPageText = (document: PDFDocument, pageIndex: number) =>
getPageTextValues(document, pageIndex).join('\n');
export const createEncryptedPdf = async (pageCount: number, label: string, password: string) => {
const document = await EncryptablePdfDocument.load(await createPdf(pageCount, label));
document.encrypt({
+176
View File
@@ -0,0 +1,176 @@
import { expect, test, type Page } from '@playwright/test';
import { PDFDocument } from 'pdf-lib';
import {
createEncryptedPdf,
createPdf,
createPreRotatedPdf,
dropFiles,
expectPdfDownload,
getPageContentText,
getPageText,
pdfDropzone,
trackPageErrors
} from './helpers';
const downloadPaginated = async (page: Page, fileName: RegExp) => {
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Paginieren & herunterladen' }).click();
return expectPdfDownload(await downloadPromise, fileName);
};
test('pagination stamps every page with an Aktenzeichen and the configured start number', async ({
page
}) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/pagination');
await dropFiles(page, pdfDropzone(page), [
{ name: 'akte.pdf', mimeType: 'application/pdf', buffer: await createPdf(3, 'Akte') }
]);
await expect(page.getByText('Seite 1 von 3')).toBeVisible();
await page.locator('#pagination-start').fill('26');
await page.locator('#pagination-text').fill('123 Ja 60/26');
await expect(page.getByTestId('pagination-example')).toContainText(
'Beispiel: 123 Ja 60/26, Bl. 26'
);
await page.getByRole('button', { name: 'Nächste Seite' }).click();
await expect(page.getByText('Seite 2 von 3')).toBeVisible();
await expect(page.getByTestId('pagination-preview')).toContainText('123 Ja 60/26, Bl. 27');
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');
expect(pageErrors).toEqual([]);
});
test('pagination can keep the additional text on the first page only', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/pagination');
await dropFiles(page, pdfDropzone(page), [
{ name: 'akte.pdf', mimeType: 'application/pdf', buffer: await createPdf(3, 'Akte') }
]);
await page.locator('#pagination-start').fill('7');
await page.locator('#pagination-text').fill('AZ 7/26');
await page.locator('#pagination-format').selectOption('s');
await page.getByLabel('Nur auf der ersten Seite').check();
await expect(page.getByTestId('pagination-example')).toContainText('AZ 7/26, S. 7');
await page.getByRole('button', { name: 'Nächste Seite' }).click();
await expect(page.getByTestId('pagination-preview')).toHaveText('S. 8');
const bytes = await downloadPaginated(page, /^akte_paginiert\.pdf$/);
const output = await PDFDocument.load(bytes);
expect(output.getPageCount()).toBe(3);
expect(getPageText(output, 0)).toContain('AZ 7/26, S. 7');
expect(getPageText(output, 1)).toContain('S. 8');
expect(getPageText(output, 1)).not.toContain('AZ 7/26');
expect(getPageText(output, 2)).toContain('S. 9');
expect(pageErrors).toEqual([]);
});
test('pagination supports number-only labels and jumps to a page', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/pagination');
await dropFiles(page, pdfDropzone(page), [
{ name: 'nummern.pdf', mimeType: 'application/pdf', buffer: await createPdf(11, 'Nummern') }
]);
await page.locator('#pagination-start').fill('42');
await page.locator('#pagination-format').selectOption('nummer');
await expect(page.getByTestId('pagination-example')).toHaveText('Beispiel: 42');
await page.getByLabel('Springe zu Seite').fill('11');
await expect(page.getByText('Seite 11 von 11')).toBeVisible();
await expect(page.getByTestId('pagination-preview')).toHaveText('52');
const bytes = await downloadPaginated(page, /^nummern_paginiert\.pdf$/);
const output = await PDFDocument.load(bytes);
expect(output.getPageCount()).toBe(11);
expect(getPageText(output, 0)).toContain('42');
expect(getPageText(output, 10)).toContain('52');
expect(pageErrors).toEqual([]);
});
test('pagination keeps the page number upright on rotated scans', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/pagination');
await dropFiles(page, pdfDropzone(page), [
{
name: 'gedreht.pdf',
mimeType: 'application/pdf',
buffer: await createPreRotatedPdf(2, 'Gedreht')
}
]);
const bytes = await downloadPaginated(page, /^gedreht_paginiert\.pdf$/);
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
// 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);
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/);
expect(pageErrors).toEqual([]);
});
test('pagination explains how to handle a password-protected PDF', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/pagination');
await dropFiles(page, pdfDropzone(page), [
{
name: 'geschuetzt.pdf',
mimeType: 'application/pdf',
buffer: await createEncryptedPdf(1, 'Geschützt', 'test-passwort')
}
]);
await expect(page.getByRole('alert')).toContainText('passwortgeschützt');
await expect(page.getByRole('alert')).toContainText('Passwort entfernen');
expect(pageErrors).toEqual([]);
});
test('merge hands the merged PDF over to pagination', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/merge');
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(2, 'Zwei') }
]);
await page.getByRole('button', { name: 'Zusammenfügen & paginieren' }).click();
await expect(page).toHaveURL(/\/tools\/pagination$/);
await expect(page.getByText('Seite 1 von 3')).toBeVisible({ timeout: 15_000 });
const bytes = await downloadPaginated(page, /^zusammengefuegt_paginiert\.pdf$/);
const output = await PDFDocument.load(bytes);
expect(output.getPageCount()).toBe(3);
expect(getPageText(output, 0)).toContain('Bl. 1');
expect(getPageText(output, 1)).toContain('Bl. 2');
expect(getPageText(output, 2)).toContain('Bl. 3');
expect(pageErrors).toEqual([]);
});
+61 -4
View File
@@ -1,4 +1,6 @@
import { expect, test } from '@playwright/test';
import { unzipSync } from 'fflate';
import { readFile } from 'node:fs/promises';
import { PDFDocument } from 'pdf-lib';
import {
createEncryptedPdf,
@@ -29,8 +31,10 @@ test('stamp presets fill the editable text and EILT is applied to every page', a
await expect(page.getByTestId('stamp-preview')).toContainText('EILT');
await expect(page.getByTestId('stamp-preview')).not.toHaveCSS('transform', 'none');
await page.getByRole('button', { name: 'Stempel anwenden', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toBeVisible({ timeout: 20_000 });
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Stempel anwenden & herunterladen' }).click();
await page.getByRole('button', { name: 'akte.pdf herunterladen' }).click();
const outputBytes = await expectPdfDownload(await downloadPromise, /^akte_stempel\.pdf$/);
const output = await PDFDocument.load(outputBytes);
expect(output.getPageCount()).toBe(3);
@@ -58,8 +62,10 @@ test('stamp custom text adds a German date and only changes selected pages', asy
await page.getByRole('checkbox', { name: 'Auf alle Seiten anwenden' }).uncheck();
await page.getByRole('button', { name: /Seite 2/ }).click();
await page.getByRole('button', { name: 'Stempel anwenden', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toBeVisible({ timeout: 20_000 });
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Stempel anwenden & herunterladen' }).click();
await page.getByRole('button', { name: 'auswahl.pdf herunterladen' }).click();
const outputBytes = await expectPdfDownload(await downloadPromise, /^auswahl_stempel\.pdf$/);
const output = await PDFDocument.load(outputBytes);
expect(output.getPageCount()).toBe(3);
@@ -89,8 +95,10 @@ test('stamp uses an image instead of text and makes the border optional', async
page.getByRole('checkbox', { name: 'Rahmen um das Bild anzeigen' })
).not.toBeChecked();
await page.getByRole('button', { name: 'Stempel anwenden', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toBeVisible({ timeout: 20_000 });
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Stempel anwenden & herunterladen' }).click();
await page.getByRole('button', { name: 'bildakte.pdf herunterladen' }).click();
const outputBytes = await expectPdfDownload(await downloadPromise, /^bildakte_stempel\.pdf$/);
const output = await PDFDocument.load(outputBytes);
expect(output.getPageCount()).toBe(2);
@@ -103,8 +111,18 @@ test('stamp uses an image instead of text and makes the border optional', async
await page.getByRole('checkbox', { name: 'Rahmen um das Bild anzeigen' }).check();
await expect(page.getByTestId('stamp-preview')).toHaveCSS('border-style', 'solid');
// Re-add the file so the changed option applies to a fresh pending row.
await page.getByRole('button', { name: 'bildakte.pdf entfernen' }).click();
await dropFiles(page, pdfDropzone(page), [
{ name: 'bildakte.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Bild') }
]);
await expect(page.getByTestId('stamp-preview').locator('img')).toBeVisible();
await page.getByRole('button', { name: 'Stempel anwenden', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toBeVisible({ timeout: 20_000 });
const borderedDownloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Stempel anwenden & herunterladen' }).click();
await page.getByRole('button', { name: 'bildakte.pdf herunterladen' }).click();
const borderedBytes = await expectPdfDownload(
await borderedDownloadPromise,
/^bildakte_stempel\.pdf$/
@@ -120,6 +138,45 @@ test('stamp uses an image instead of text and makes the border optional', async
expect(pageErrors).toEqual([]);
});
test('stamp batch applies to every file and bundles results in one ZIP', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/stamp');
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(2, 'Zwei') },
{
name: 'kaputt.pdf',
mimeType: 'application/pdf',
buffer: Buffer.from('kein gueltiges PDF')
},
{
name: 'geschuetzt.pdf',
mimeType: 'application/pdf',
buffer: await createEncryptedPdf(1, 'Geschützt', 'test-passwort')
}
]);
await expect(page.getByText('Dateien (4)')).toBeVisible();
// With several files the stamp is applied to every page and no preview is loaded.
await expect(page.getByRole('checkbox', { name: 'Auf alle Seiten anwenden' })).toHaveCount(0);
await expect(page.getByRole('heading', { name: 'Vorschau' })).toHaveCount(0);
await page.getByRole('button', { name: 'Stempel anwenden', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toHaveCount(2, { timeout: 30_000 });
await expect(page.getByText('Fehler')).toHaveCount(2);
await expect(page.getByText('Dieses PDF ist passwortgeschützt')).toBeVisible();
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Alle herunterladen' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/^stempel_\d{4}-\d{2}-\d{2}\.zip$/);
const zipPath = await download.path();
expect(zipPath).not.toBeNull();
const entries = Object.keys(unzipSync(new Uint8Array(await readFile(zipPath!))));
expect(entries.sort()).toEqual(['eins_stempel.pdf', 'zwei_stempel.pdf']);
expect(pageErrors).toEqual([]);
});
test('stamp explains how to handle a password-protected PDF', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/stamp');
+1
View File
@@ -12,6 +12,7 @@ test('start page exposes only working tools and forwards a dropped ZIP to beA',
await page.goto('/');
await expect(page.getByRole('heading', { name: 'Werkzeugkasten für beA' })).toBeVisible();
await expect(page.getByRole('link', { name: /Stempeln/ })).toBeVisible();
await expect(page.getByRole('link', { name: /Paginieren/ })).toBeVisible();
await expect(page.getByRole('link', { name: /Signieren/ })).toHaveCount(0);
await dropFiles(page, 'window', [