batch + stamping
Build and Push Docker Image / build (push) Successful in 1m35s

This commit is contained in:
2026-08-28 08:02:10 +02:00
parent 57b38fe2c7
commit ea21fc000f
13 changed files with 2237 additions and 648 deletions
+377 -16
View File
@@ -1,6 +1,14 @@
import { expect, test, type Download, type Locator, type Page } from '@playwright/test';
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
import { zipSync } from 'fflate';
import {
decodePDFRawStream,
PDFArray,
PDFDocument,
PDFRawStream,
StandardFonts,
rgb
} from 'pdf-lib';
import { PDFDocument as EncryptablePdfDocument } from '@cantoo/pdf-lib';
import { unzipSync, zipSync } from 'fflate';
import { readFile } from 'node:fs/promises';
const createPdf = async (pageCount: number, label: string) => {
@@ -19,6 +27,35 @@ const createPdf = async (pageCount: number, label: string) => {
return Buffer.from(await document.save());
};
const createStampPng = (page: Page) =>
page.evaluate(
async () =>
new Promise<string>((resolve, reject) => {
const canvas = document.createElement('canvas');
canvas.width = 32;
canvas.height = 32;
const context = canvas.getContext('2d');
if (!context) {
reject(new Error('Kein 2D-Kontext'));
return;
}
context.fillStyle = '#cc1a1a';
context.fillRect(0, 0, 32, 32);
canvas.toBlob(async (blob) => {
if (!blob) {
reject(new Error('PNG konnte nicht erzeugt werden'));
return;
}
const bytes = new Uint8Array(await blob.arrayBuffer());
let binary = '';
bytes.forEach((byte) => {
binary += String.fromCharCode(byte);
});
resolve(btoa(binary));
}, 'image/png');
})
);
const dropFiles = async (
page: Page,
target: Locator | 'window',
@@ -69,6 +106,32 @@ const expectPdfDownload = async (download: Download, name: RegExp) => {
return bytes;
};
const getPageContentStreamCount = (document: PDFDocument, pageIndex: number) => {
const contents = document.getPage(pageIndex).node.Contents();
if (!contents) return 0;
return contents instanceof PDFArray ? contents.size() : 1;
};
const getPageContentText = (document: PDFDocument, pageIndex: number) => {
const contents = document.getPage(pageIndex).node.Contents();
const refs = contents instanceof PDFArray ? contents.asArray() : contents ? [contents] : [];
return refs
.map((ref) => document.context.lookup(ref))
.filter((stream): stream is PDFRawStream => stream instanceof PDFRawStream)
.map((stream) => Buffer.from(decodePDFRawStream(stream).decode()).toString('latin1'))
.join('\n');
};
const createEncryptedPdf = async (pageCount: number, label: string, password: string) => {
const document = await EncryptablePdfDocument.load(await createPdf(pageCount, label));
document.encrypt({
userPassword: password,
ownerPassword: 'owner-passwort',
permissions: { printing: true, copying: true }
});
return Buffer.from(await document.save());
};
const trackPageErrors = (page: Page) => {
const errors: Error[] = [];
page.on('pageerror', (error) => errors.push(error));
@@ -121,6 +184,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: /Signieren/ })).toHaveCount(0);
await dropFiles(page, 'window', [
@@ -209,13 +273,13 @@ test('watermark accepts numeric controls and downloads text and image watermarks
{ name: 'marke.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Marke') }
]);
await expect(page.getByRole('heading', { name: 'Wasserzeichen-Einstellungen' })).toBeVisible({
timeout: 15_000
});
await expect(page.getByRole('heading', { name: 'Wasserzeichen-Einstellungen' })).toBeVisible();
await page.locator('#watermark-color').fill('#00ff00');
await page.locator('#watermark-opacity').fill('0.45');
await page.getByRole('button', { name: 'Wasserzeichen anwenden', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toBeVisible({ timeout: 15_000 });
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Wasserzeichen anwenden & herunterladen' }).click();
await page.getByRole('button', { name: 'marke.pdf herunterladen' }).click();
await expectPdfDownload(await downloadPromise, /^marke_wasserzeichen\.pdf$/);
await page.getByLabel('Bild', { exact: true }).check();
@@ -229,15 +293,176 @@ test('watermark accepts numeric controls and downloads text and image watermarks
)
}
]);
// The file is already done, so reset it to pending by removing and re-adding it.
await page.getByRole('button', { name: 'marke.pdf entfernen' }).click();
await dropFiles(page, pdfDropzone(page), [
{ name: 'marke.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Marke') }
]);
await page.getByRole('button', { name: 'Wasserzeichen anwenden', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toBeVisible({ timeout: 15_000 });
const imageDownloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Wasserzeichen anwenden & herunterladen' }).click();
await page.getByRole('button', { name: 'marke.pdf herunterladen' }).click();
await expectPdfDownload(await imageDownloadPromise, /^marke_wasserzeichen\.pdf$/);
await expect(page.getByText('Fehler beim Anwenden')).toHaveCount(0);
expect(pageErrors).toEqual([]);
});
test('encrypt creates a protected PDF and decrypt rebuilds it with the password', async ({
test('stamp presets fill the editable text and EILT is applied to every page', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/stamp');
await dropFiles(page, pdfDropzone(page), [
{ name: 'akte.pdf', mimeType: 'application/pdf', buffer: await createPdf(3, 'Akte') }
]);
const stampInput = page.locator('#stamp-text');
for (const preset of ['BEGLAUBIGT', 'AUSFERTIGUNG', 'KOPIE', 'EILT', 'ENTWURF', 'ERLEDIGT']) {
await page.getByRole('button', { name: preset, exact: true }).click();
await expect(stampInput).toHaveValue(preset);
}
await page.getByRole('button', { name: 'EILT', exact: true }).click();
await page.locator('#stamp-rotation').fill('20');
await expect(page.getByTestId('stamp-preview')).toContainText('EILT');
await expect(page.getByTestId('stamp-preview')).not.toHaveCSS('transform', 'none');
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Stempel anwenden & herunterladen' }).click();
const outputBytes = await expectPdfDownload(await downloadPromise, /^akte_stempel\.pdf$/);
const output = await PDFDocument.load(outputBytes);
expect(output.getPageCount()).toBe(3);
for (let pageIndex = 0; pageIndex < output.getPageCount(); pageIndex += 1) {
expect(getPageContentStreamCount(output, pageIndex)).toBeGreaterThan(1);
}
expect(pageErrors).toEqual([]);
});
test('stamp custom text adds a German date and only changes selected pages', async ({ page }) => {
const pageErrors = trackPageErrors(page);
const inputBytes = await createPdf(3, 'Auswahl');
const input = await PDFDocument.load(inputBytes);
const originalStreamCounts = input
.getPages()
.map((_, pageIndex) => getPageContentStreamCount(input, pageIndex));
await page.goto('/tools/stamp');
await dropFiles(page, pdfDropzone(page), [
{ name: 'auswahl.pdf', mimeType: 'application/pdf', buffer: inputBytes }
]);
await page.locator('#stamp-text').fill('nur hier');
await page.getByRole('checkbox', { name: 'Datum anhängen' }).check();
await expect(page.getByTestId('stamp-preview')).toHaveText(/NUR HIER \d{2}\.\d{2}\.\d{4}/);
await page.getByRole('checkbox', { name: 'Auf alle Seiten anwenden' }).uncheck();
await page.getByRole('button', { name: /Seite 2/ }).click();
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Stempel anwenden & herunterladen' }).click();
const outputBytes = await expectPdfDownload(await downloadPromise, /^auswahl_stempel\.pdf$/);
const output = await PDFDocument.load(outputBytes);
expect(output.getPageCount()).toBe(3);
expect(getPageContentStreamCount(output, 0)).toBe(originalStreamCounts[0]);
expect(getPageContentStreamCount(output, 1)).toBeGreaterThan(originalStreamCounts[1]);
expect(getPageContentStreamCount(output, 2)).toBe(originalStreamCounts[2]);
expect(pageErrors).toEqual([]);
});
test('stamp uses an image instead of text and makes the border optional', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/stamp');
await dropFiles(page, pdfDropzone(page), [
{ name: 'bildakte.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Bild') }
]);
await page.locator('#stamp-image').setInputFiles({
name: 'stempel.png',
mimeType: 'image/png',
buffer: Buffer.from(await createStampPng(page), 'base64')
});
await expect(page.getByText('stempel.png')).toBeVisible();
await expect(page.getByTestId('stamp-preview').locator('img')).toBeVisible();
await expect(page.locator('#stamp-text')).toHaveCount(0);
await expect(page.getByRole('checkbox', { name: 'Rahmen um das Bild anzeigen' })).toBeVisible();
await expect(
page.getByRole('checkbox', { name: 'Rahmen um das Bild anzeigen' })
).not.toBeChecked();
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Stempel anwenden & herunterladen' }).click();
const outputBytes = await expectPdfDownload(await downloadPromise, /^bildakte_stempel\.pdf$/);
const output = await PDFDocument.load(outputBytes);
expect(output.getPageCount()).toBe(2);
// Image without frame: the image XObject is drawn, no stroked rectangle is present.
for (const pageIndex of [0, 1]) {
const content = getPageContentText(output, pageIndex);
expect(content).toContain(' Do\n');
expect(content).not.toContain(' RG');
}
await page.getByRole('checkbox', { name: 'Rahmen um das Bild anzeigen' }).check();
await expect(page.getByTestId('stamp-preview')).toHaveCSS('border-style', 'solid');
const borderedDownloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Stempel anwenden & herunterladen' }).click();
const borderedBytes = await expectPdfDownload(
await borderedDownloadPromise,
/^bildakte_stempel\.pdf$/
);
const bordered = await PDFDocument.load(borderedBytes);
// The optional frame adds a stroked rectangle around the drawn image.
for (const pageIndex of [0, 1]) {
const content = getPageContentText(bordered, pageIndex);
expect(content).toContain(' Do\n');
expect(content).toContain(' RG');
expect(content).toMatch(/\bh\nS\n/);
}
expect(pageErrors).toEqual([]);
});
test('stamp explains how to handle a password-protected PDF', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/stamp');
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('watermark batch applies to every file and bundles results in one ZIP', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/watermark');
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')
}
]);
await expect(page.getByText('Dateien (3)')).toBeVisible();
await page.getByRole('button', { name: 'Wasserzeichen anwenden', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toHaveCount(2, { timeout: 15_000 });
await expect(page.getByText('Fehler')).toBeVisible();
// With several files the per-file page selection is replaced by all pages.
await expect(page.getByRole('checkbox', { name: 'Auf alle Seiten anwenden' })).toHaveCount(0);
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Alle herunterladen' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/^wasserzeichen_\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_wasserzeichen.pdf', 'zwei_wasserzeichen.pdf']);
expect(pageErrors).toEqual([]);
});
test('encrypt protects a dropped PDF and decrypt rebuilds it with the password', async ({
page
}) => {
const pageErrors = trackPageErrors(page);
@@ -248,10 +473,14 @@ test('encrypt creates a protected PDF and decrypt rebuilds it with the password'
await page.locator('#encrypt-password').fill('test-passwort');
await page.locator('#encrypt-password-confirmation').fill('test-passwort');
await page.getByRole('button', { name: 'Passwort setzen', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toBeVisible({ timeout: 15_000 });
const encryptedDownloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Passwort setzen & herunterladen' }).click();
const encryptedDownload = await encryptedDownloadPromise;
const encryptedBytes = await expectPdfDownload(encryptedDownload, /^geheim_geschuetzt\.pdf$/);
await page.getByRole('button', { name: 'geheim.pdf herunterladen' }).click();
const encryptedBytes = await expectPdfDownload(
await encryptedDownloadPromise,
/^geheim_geschuetzt\.pdf$/
);
await page.goto('/tools/decrypt');
await dropFiles(page, pdfDropzone(page), [
@@ -262,27 +491,159 @@ test('encrypt creates a protected PDF and decrypt rebuilds it with the password'
}
]);
await page.locator('#decrypt-password').fill('test-passwort');
await page.getByRole('button', { name: 'Passwort entfernen', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toBeVisible({ timeout: 20_000 });
const decryptedDownloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Passwort entfernen & herunterladen' }).click();
await page.getByRole('button', { name: 'geheim_geschuetzt.pdf herunterladen' }).click();
await expectPdfDownload(await decryptedDownloadPromise, /^geheim_geschuetzt_entsperrt\.pdf$/);
expect(pageErrors).toEqual([]);
});
test('compress previews result size before downloading', async ({ page }) => {
test('encrypt batch protects every file and bundles results in one ZIP', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/encrypt');
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 expect(page.getByText('Dateien (2)')).toBeVisible();
await page.locator('#encrypt-password').fill('test-passwort');
await page.locator('#encrypt-password-confirmation').fill('test-passwort');
await page.getByRole('button', { name: 'Passwort setzen', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toHaveCount(2, { timeout: 15_000 });
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Alle herunterladen' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/^geschuetzt_\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_geschuetzt.pdf', 'zwei_geschuetzt.pdf']);
expect(pageErrors).toEqual([]);
});
test('decrypt batch unlocks every file and bundles results in one ZIP', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/decrypt');
await dropFiles(page, pdfDropzone(page), [
{
name: 'eins_geschuetzt.pdf',
mimeType: 'application/pdf',
buffer: await createEncryptedPdf(1, 'Eins', 'test-passwort')
},
{
name: 'zwei_geschuetzt.pdf',
mimeType: 'application/pdf',
buffer: await createEncryptedPdf(2, 'Zwei', 'test-passwort')
},
{
name: 'kaputt.pdf',
mimeType: 'application/pdf',
buffer: Buffer.from('kein gueltiges PDF')
}
]);
await expect(page.getByText('Dateien (3)')).toBeVisible();
await page.locator('#decrypt-password').fill('test-passwort');
await page.getByRole('button', { name: 'Passwort entfernen', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toHaveCount(2, { timeout: 20_000 });
await expect(page.getByText(/Fehler beim Entsperren/)).toBeVisible();
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Alle herunterladen' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/^entsperrt_\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_geschuetzt_entsperrt.pdf',
'zwei_geschuetzt_entsperrt.pdf'
]);
expect(pageErrors).toEqual([]);
});
test('compress processes a single dropped file and offers its download per row', async ({
page
}) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/compress');
await dropFiles(page, pdfDropzone(page), [
{ name: 'gross.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Komprimieren') }
]);
await expect(page.getByText('Dateien (1)')).toBeVisible();
await page.getByRole('button', { name: 'Komprimieren', exact: true }).click();
await expect(page.getByText(/Ergebnisgröße:/)).toBeVisible({ timeout: 20_000 });
await expect(page.getByText('Fertig ✓')).toBeVisible({ timeout: 20_000 });
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'PDF herunterladen' }).click();
await page.getByRole('button', { name: 'gross.pdf herunterladen' }).click();
await expectPdfDownload(await downloadPromise, /^gross_komprimiert\.pdf$/);
expect(pageErrors).toEqual([]);
});
test('compress appends further files and bundles all results in one ZIP', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/compress');
await dropFiles(page, pdfDropzone(page), [
{ name: 'eins.pdf', mimeType: 'application/pdf', buffer: await createPdf(1, 'Eins') }
]);
await dropFiles(page, pdfDropzone(page), [
{ name: 'zwei.pdf', mimeType: 'application/pdf', buffer: await createPdf(2, 'Zwei') }
]);
await expect(page.getByText('Dateien (2)')).toBeVisible();
await page.getByRole('button', { name: 'Komprimieren', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toHaveCount(2, { timeout: 30_000 });
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Alle herunterladen' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/^komprimiert_\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_komprimiert.pdf', 'zwei_komprimiert.pdf']);
expect(pageErrors).toEqual([]);
});
test('compress batch keeps processing after a failing file and supports retry and removal', async ({
page
}) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/compress');
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 gueltiges PDF')
}
]);
await page.getByRole('button', { name: 'Komprimieren', exact: true }).click();
await expect(page.getByText('Fertig ✓')).toHaveCount(1, { timeout: 30_000 });
await expect(page.getByText('Fehler')).toBeVisible();
// Only one result exists, so the bulk ZIP action stays hidden.
await expect(page.getByRole('button', { name: 'Alle herunterladen' })).toHaveCount(0);
// Retrying the broken file fails again without disturbing the finished row.
await page.getByRole('button', { name: 'kaputt.pdf erneut versuchen' }).click();
await expect(page.getByText('Fertig ✓')).toHaveCount(1);
await expect(page.getByText('Fehler')).toBeVisible();
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'gut.pdf herunterladen' }).click();
await expectPdfDownload(await downloadPromise, /^gut_komprimiert\.pdf$/);
await page.getByRole('button', { name: 'kaputt.pdf entfernen' }).click();
await expect(page.getByText('kaputt.pdf')).toHaveCount(0);
await expect(page.getByRole('button', { name: 'gut.pdf herunterladen' })).toBeVisible();
expect(pageErrors).toEqual([]);
});
test('convert bundles a multi-page PDF as a correctly named ZIP', async ({ page }) => {
const pageErrors = trackPageErrors(page);
await page.goto('/tools/convert');