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