328 lines
10 KiB
TypeScript
328 lines
10 KiB
TypeScript
import { expect, type Download, type Locator, type Page } from '@playwright/test';
|
||
import {
|
||
decodePDFRawStream,
|
||
degrees,
|
||
PDFArray,
|
||
PDFDocument,
|
||
PDFRawStream,
|
||
StandardFonts,
|
||
rgb
|
||
} from 'pdf-lib';
|
||
import { PDFDocument as EncryptablePdfDocument } from '@cantoo/pdf-lib';
|
||
import { zipSync } from 'fflate';
|
||
import { readFile } from 'node:fs/promises';
|
||
|
||
export const createPdf = async (pageCount: number, label: string) => {
|
||
const document = await PDFDocument.create();
|
||
const font = await document.embedFont(StandardFonts.Helvetica);
|
||
for (let pageNumber = 1; pageNumber <= pageCount; pageNumber += 1) {
|
||
const page = document.addPage([420, 594]);
|
||
page.drawText(`${label} – Seite ${pageNumber}`, {
|
||
x: 40,
|
||
y: 520,
|
||
size: 20,
|
||
font,
|
||
color: rgb(0.1, 0.2, 0.4)
|
||
});
|
||
}
|
||
return Buffer.from(await document.save());
|
||
};
|
||
|
||
export 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');
|
||
})
|
||
);
|
||
|
||
export const dropFiles = async (
|
||
page: Page,
|
||
target: Locator | 'window',
|
||
files: Array<{ name: string; mimeType: string; buffer: Buffer }>
|
||
) => {
|
||
await page.locator('[data-dropzone-ready="true"]').first().waitFor();
|
||
const dataTransfer = await page.evaluateHandle(
|
||
(droppedFiles) => {
|
||
const transfer = new DataTransfer();
|
||
for (const droppedFile of droppedFiles) {
|
||
const bytes = Uint8Array.from(atob(droppedFile.base64), (character) =>
|
||
character.charCodeAt(0)
|
||
);
|
||
transfer.items.add(
|
||
new File([bytes], droppedFile.name, {
|
||
type: droppedFile.mimeType,
|
||
lastModified: Date.now()
|
||
})
|
||
);
|
||
}
|
||
return transfer;
|
||
},
|
||
files.map((file) => ({
|
||
name: file.name,
|
||
mimeType: file.mimeType,
|
||
base64: file.buffer.toString('base64')
|
||
}))
|
||
);
|
||
|
||
if (target === 'window') {
|
||
await page.evaluate((transfer) => {
|
||
window.dispatchEvent(
|
||
new DragEvent('drop', { dataTransfer: transfer, bubbles: true, cancelable: true })
|
||
);
|
||
}, dataTransfer);
|
||
} else {
|
||
await target.dispatchEvent('drop', { dataTransfer });
|
||
}
|
||
await dataTransfer.dispose();
|
||
};
|
||
|
||
export const expectPdfDownload = async (download: Download, name: RegExp) => {
|
||
expect(download.suggestedFilename()).toMatch(name);
|
||
const path = await download.path();
|
||
expect(path).not.toBeNull();
|
||
const bytes = await readFile(path!);
|
||
expect(bytes.subarray(0, 5).toString()).toBe('%PDF-');
|
||
return bytes;
|
||
};
|
||
|
||
export const getPageContentStreamCount = (document: PDFDocument, pageIndex: number) => {
|
||
const contents = document.getPage(pageIndex).node.Contents();
|
||
if (!contents) return 0;
|
||
return contents instanceof PDFArray ? contents.size() : 1;
|
||
};
|
||
|
||
export 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 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 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(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;
|
||
};
|
||
|
||
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({
|
||
userPassword: password,
|
||
ownerPassword: 'owner-passwort',
|
||
permissions: { printing: true, copying: true }
|
||
});
|
||
return Buffer.from(await document.save());
|
||
};
|
||
|
||
export const createPreRotatedPdf = async (pageCount: number, label: string) => {
|
||
const document = await PDFDocument.create();
|
||
const font = await document.embedFont(StandardFonts.Helvetica);
|
||
for (let pageNumber = 1; pageNumber <= pageCount; pageNumber += 1) {
|
||
const page = document.addPage([420, 594]);
|
||
page.drawText(`${label} – Seite ${pageNumber}`, {
|
||
x: 40,
|
||
y: 520,
|
||
size: 20,
|
||
font,
|
||
color: rgb(0.1, 0.2, 0.4)
|
||
});
|
||
if (pageNumber === 1) page.setRotation(degrees(90));
|
||
}
|
||
return Buffer.from(await document.save());
|
||
};
|
||
|
||
export const trackPageErrors = (page: Page) => {
|
||
const errors: Error[] = [];
|
||
page.on('pageerror', (error) => errors.push(error));
|
||
return errors;
|
||
};
|
||
|
||
export const pdfDropzone = (page: Page) =>
|
||
page.getByRole('button', { name: 'PDF-Dateien hinzufügen' });
|
||
export const zipDropzone = (page: Page) =>
|
||
page.getByRole('button', { name: 'beA-ZIP- oder PDF-Dateien hinzufügen' });
|
||
|
||
export const createZip = async (label: string, pageCount = 1) =>
|
||
Buffer.from(zipSync({ [`${label}.pdf`]: await createPdf(pageCount, label) }));
|
||
|
||
/** ZIP with multiple named single-page PDF attachments. */
|
||
export const createMultiFileZip = async (labels: string[]) =>
|
||
Buffer.from(
|
||
zipSync(
|
||
Object.fromEntries(
|
||
await Promise.all(labels.map(async (label) => [`${label}.pdf`, await createPdf(1, label)]))
|
||
)
|
||
)
|
||
);
|
||
|
||
export const newZipButton = (page: Page) =>
|
||
page.getByRole('button', { name: 'Neue Datei bearbeiten' });
|
||
export const settingsButton = (page: Page) =>
|
||
page.getByRole('button', { name: 'Einstellungen', exact: true });
|
||
export const replaceZipInput = (page: Page) => page.locator('input[data-zip-picker="replace-zip"]');
|
||
export const appendZipInput = (page: Page) => page.locator('input[data-zip-picker="append-zip"]');
|
||
export const workspaceStatus = (page: Page, pattern: RegExp) => page.getByText(pattern);
|
||
|
||
export const waitForLoadedWorkspace = async (
|
||
page: Page,
|
||
status: RegExp,
|
||
options?: { timeout?: number }
|
||
) => {
|
||
await expect(newZipButton(page)).toBeVisible();
|
||
await expect(workspaceStatus(page, status)).toBeVisible({ timeout: options?.timeout ?? 15_000 });
|
||
};
|
||
|
||
export const replaceThroughDialog = async (page: Page, zip: Buffer, confirm: boolean) => {
|
||
await newZipButton(page).click();
|
||
await replaceZipInput(page).setInputFiles([
|
||
{ name: 'archiv-b.zip', mimeType: 'application/zip', buffer: zip }
|
||
]);
|
||
await expect(page.getByText('Aktuelle Bearbeitung ersetzen?')).toBeVisible();
|
||
if (confirm) {
|
||
await page.getByRole('button', { name: 'Neue Datei öffnen' }).click();
|
||
} else {
|
||
await page.getByRole('button', { name: 'Abbrechen' }).click();
|
||
}
|
||
await expect(page.getByText('Aktuelle Bearbeitung ersetzen?')).toBeHidden();
|
||
};
|