refactoring, numbering plan
This commit is contained in:
1 parent
b5279434b2
commit
3b04039aa9
47 files changed
+3485
-953
No files matched your search
@@ -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
@@ -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({
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user