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,83 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import {
|
||||
DEFAULT_EXHIBIT_PREFIX,
|
||||
MAX_EXHIBIT_PREFIX_LENGTH,
|
||||
MAX_EXHIBIT_START_NUMBER,
|
||||
MIN_EXHIBIT_START_NUMBER,
|
||||
buildExhibitFileName,
|
||||
buildExhibitLabel,
|
||||
buildExhibitPrefixSlug,
|
||||
getExhibitNumberWidth,
|
||||
normalizeExhibitPrefix,
|
||||
normalizeExhibitStartNumber
|
||||
} from '../../src/lib/exhibit-naming';
|
||||
|
||||
test('the prefix is trimmed and capped', () => {
|
||||
expect(normalizeExhibitPrefix(' Anlage K ')).toBe('Anlage K');
|
||||
expect(normalizeExhibitPrefix('')).toBe('');
|
||||
expect(normalizeExhibitPrefix('A'.repeat(MAX_EXHIBIT_PREFIX_LENGTH + 10))).toHaveLength(
|
||||
MAX_EXHIBIT_PREFIX_LENGTH
|
||||
);
|
||||
});
|
||||
|
||||
test('labels use one space and never pad the number', () => {
|
||||
expect(buildExhibitLabel('Anlage K', 6)).toBe('Anlage K 6');
|
||||
expect(buildExhibitLabel(' Anlage ', 1)).toBe('Anlage 1');
|
||||
expect(buildExhibitLabel('Anlage', 98)).toBe('Anlage 98');
|
||||
});
|
||||
|
||||
test('the padding width follows the highest assigned number', () => {
|
||||
expect(getExhibitNumberWidth(9)).toBe(2);
|
||||
expect(getExhibitNumberWidth(99)).toBe(2);
|
||||
expect(getExhibitNumberWidth(100)).toBe(3);
|
||||
expect(getExhibitNumberWidth(1000)).toBe(4);
|
||||
});
|
||||
|
||||
test('file names prefix the number and keep the original base name', () => {
|
||||
expect(
|
||||
buildExhibitFileName({
|
||||
prefix: 'Anlage K',
|
||||
number: 6,
|
||||
highestNumber: 8,
|
||||
originalName: 'Klageschrift.pdf'
|
||||
})
|
||||
).toBe('Anlage_K_06_Klageschrift.pdf');
|
||||
|
||||
expect(
|
||||
buildExhibitFileName({
|
||||
prefix: 'Anlage K',
|
||||
number: 98,
|
||||
highestNumber: 100,
|
||||
originalName: 'Anlage.PDF'
|
||||
})
|
||||
).toBe('Anlage_K_098_Anlage.pdf');
|
||||
});
|
||||
|
||||
test('unsafe characters never reach the file name', () => {
|
||||
expect(
|
||||
buildExhibitFileName({
|
||||
prefix: 'Anlage K/1',
|
||||
number: 1,
|
||||
highestNumber: 1,
|
||||
originalName: 'a:b*c?.pdf'
|
||||
})
|
||||
).toBe('Anlage_K1_01_abc.pdf');
|
||||
});
|
||||
|
||||
test('an empty prefix falls back to the default slug', () => {
|
||||
expect(buildExhibitPrefixSlug(' ')).toBe(DEFAULT_EXHIBIT_PREFIX);
|
||||
expect(
|
||||
buildExhibitFileName({ prefix: '', number: 3, highestNumber: 3, originalName: 'x.pdf' })
|
||||
).toBe('Anlage_03_x.pdf');
|
||||
});
|
||||
|
||||
test('the start number is clamped and survives an empty field', () => {
|
||||
expect(normalizeExhibitStartNumber(1)).toBe(1);
|
||||
expect(normalizeExhibitStartNumber(0)).toBe(MIN_EXHIBIT_START_NUMBER);
|
||||
expect(normalizeExhibitStartNumber(-5)).toBe(MIN_EXHIBIT_START_NUMBER);
|
||||
expect(normalizeExhibitStartNumber(6.6)).toBe(7);
|
||||
expect(normalizeExhibitStartNumber(undefined)).toBe(1);
|
||||
expect(normalizeExhibitStartNumber(null)).toBe(1);
|
||||
expect(normalizeExhibitStartNumber('')).toBe(1);
|
||||
expect(normalizeExhibitStartNumber(1_000_000)).toBe(MAX_EXHIBIT_START_NUMBER);
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import {
|
||||
ENCRYPTED_PDF_MESSAGE,
|
||||
TextOverlayError,
|
||||
classifyPdfError,
|
||||
pdfLoadErrorMessage,
|
||||
pdfProcessingErrorMessage
|
||||
} from '../../src/lib/pdf-errors';
|
||||
|
||||
const encryptedError = () =>
|
||||
new Error(
|
||||
'Input document to `PDFDocument.load` is encrypted. You can use `PDFDocument.load(..., { ignoreEncryption: true })`'
|
||||
);
|
||||
|
||||
test('overlay failures keep their own copy', () => {
|
||||
const error = new TextOverlayError('Der Text ist zu lang.');
|
||||
|
||||
expect(classifyPdfError(error)).toBe('text-overlay');
|
||||
expect(pdfProcessingErrorMessage(error, 'Fallback')).toBe('Der Text ist zu lang.');
|
||||
expect(pdfLoadErrorMessage(error)).toBe('Der Text ist zu lang.');
|
||||
});
|
||||
|
||||
test('encrypted PDFs map to the shared password hint', () => {
|
||||
expect(classifyPdfError(encryptedError())).toBe('encrypted');
|
||||
expect(pdfProcessingErrorMessage(encryptedError(), 'Fallback')).toBe(ENCRYPTED_PDF_MESSAGE);
|
||||
expect(pdfLoadErrorMessage(encryptedError())).toBe(ENCRYPTED_PDF_MESSAGE);
|
||||
});
|
||||
|
||||
test('unknown failures fall back to the caller copy', () => {
|
||||
const error = new Error('Invalid PDF structure');
|
||||
|
||||
expect(classifyPdfError(error)).toBe('unknown');
|
||||
expect(pdfProcessingErrorMessage(error, 'Fehler beim Drehen der Seiten.')).toBe(
|
||||
'Fehler beim Drehen der Seiten.'
|
||||
);
|
||||
expect(pdfLoadErrorMessage(error, 'Eigene Meldung.')).toBe('Eigene Meldung.');
|
||||
expect(pdfLoadErrorMessage(error)).toContain('PDF konnte nicht geladen werden');
|
||||
// Tool-specific copy must never be overridden by the classification.
|
||||
expect(pdfProcessingErrorMessage('boom', 'Eigene Meldung.')).toBe('Eigene Meldung.');
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import { TextOverlayError } from '../../src/lib/pdf-errors';
|
||||
import type { OverlayPosition } from '../../src/lib/pdf-overlay';
|
||||
import {
|
||||
MAX_FREE_TEXT_LENGTH,
|
||||
PAGINATION_FORMATS,
|
||||
buildPaginationLabel,
|
||||
normalizeFreeText,
|
||||
normalizeStartNumber,
|
||||
paginatePdf,
|
||||
type PaginationFormat,
|
||||
type PaginationOptions,
|
||||
type PaginationTextScope
|
||||
} from '../../src/lib/pdf-pagination';
|
||||
import { getPageTextValues } from '../e2e/helpers';
|
||||
|
||||
const labelOptions = (
|
||||
overrides: Partial<{
|
||||
startNumber: number;
|
||||
freeText: string;
|
||||
textScope: PaginationTextScope;
|
||||
format: PaginationFormat;
|
||||
}> = {}
|
||||
) => ({
|
||||
startNumber: 1,
|
||||
freeText: '',
|
||||
textScope: 'all' as const,
|
||||
format: 'blatt' as const,
|
||||
...overrides
|
||||
});
|
||||
|
||||
const paginationOptions = (overrides: Partial<PaginationOptions> = {}): PaginationOptions => ({
|
||||
startNumber: 1,
|
||||
freeText: '',
|
||||
textScope: 'all',
|
||||
format: 'blatt',
|
||||
position: 'bottom-center' as OverlayPosition,
|
||||
fontSize: 11,
|
||||
...overrides
|
||||
});
|
||||
|
||||
/** Blank pages, so the stamped sheet number is the only text in the output. */
|
||||
const createPdf = async (pageCount: number, size: [number, number] = [420, 594]) => {
|
||||
const document = await PDFDocument.create();
|
||||
|
||||
for (let index = 0; index < pageCount; index += 1) document.addPage(size);
|
||||
|
||||
return new Uint8Array(await document.save());
|
||||
};
|
||||
|
||||
test('every format is offered with a rendered example label', () => {
|
||||
expect(PAGINATION_FORMATS.map((format) => format.value)).toEqual([
|
||||
'blatt',
|
||||
'seite',
|
||||
's',
|
||||
'nummer'
|
||||
]);
|
||||
expect(PAGINATION_FORMATS.map((format) => format.label)).toEqual([
|
||||
'Bl. 1',
|
||||
'Seite 1',
|
||||
'S. 1',
|
||||
'1'
|
||||
]);
|
||||
});
|
||||
|
||||
test('label combines the free text with the ascending sheet number', () => {
|
||||
expect(buildPaginationLabel(0, labelOptions({ startNumber: 26 }))).toBe('Bl. 26');
|
||||
expect(buildPaginationLabel(2, labelOptions({ startNumber: 26 }))).toBe('Bl. 28');
|
||||
expect(buildPaginationLabel(0, labelOptions({ freeText: ' 123 Ja 60/26 ' }))).toBe(
|
||||
'123 Ja 60/26, Bl. 1'
|
||||
);
|
||||
expect(buildPaginationLabel(0, labelOptions({ format: 'seite', startNumber: 6 }))).toBe(
|
||||
'Seite 6'
|
||||
);
|
||||
expect(buildPaginationLabel(0, labelOptions({ format: 's', startNumber: 6 }))).toBe('S. 6');
|
||||
expect(buildPaginationLabel(0, labelOptions({ format: 'nummer', startNumber: 6 }))).toBe('6');
|
||||
// No leading zeros — a start number of 6 is not "06".
|
||||
expect(buildPaginationLabel(0, labelOptions({ startNumber: 6 }))).toBe('Bl. 6');
|
||||
});
|
||||
|
||||
test('the free text is dropped on later pages for the first-page scope', () => {
|
||||
const options = labelOptions({ freeText: 'AZ 7/26', textScope: 'first', format: 's' });
|
||||
|
||||
expect(buildPaginationLabel(0, options)).toBe('AZ 7/26, S. 1');
|
||||
expect(buildPaginationLabel(1, options)).toBe('S. 2');
|
||||
});
|
||||
|
||||
test('start number and free text are normalised at the boundary', () => {
|
||||
expect(normalizeStartNumber(0)).toBe(1);
|
||||
expect(normalizeStartNumber(-5)).toBe(1);
|
||||
expect(normalizeStartNumber(2.6)).toBe(3);
|
||||
expect(normalizeStartNumber(Number.NaN)).toBe(1);
|
||||
expect(normalizeStartNumber(1_000_000)).toBe(99_999);
|
||||
|
||||
expect(normalizeFreeText(' 123 Ja 60/26 ')).toBe('123 Ja 60/26');
|
||||
expect(normalizeFreeText('x'.repeat(90))).toHaveLength(MAX_FREE_TEXT_LENGTH);
|
||||
});
|
||||
|
||||
test('paginatePdf stamps each page exactly once', async () => {
|
||||
const output = await paginatePdf(
|
||||
await createPdf(3),
|
||||
paginationOptions({ startNumber: 26, freeText: '123 Ja 60/26' })
|
||||
);
|
||||
const document = await PDFDocument.load(output);
|
||||
|
||||
expect(document.getPageCount()).toBe(3);
|
||||
expect(getPageTextValues(document, 0)).toEqual(['123 Ja 60/26, Bl. 26']);
|
||||
expect(getPageTextValues(document, 1)).toEqual(['123 Ja 60/26, Bl. 27']);
|
||||
expect(getPageTextValues(document, 2)).toEqual(['123 Ja 60/26, Bl. 28']);
|
||||
});
|
||||
|
||||
test('paginatePdf honours the first-page-only scope', async () => {
|
||||
const output = await paginatePdf(
|
||||
await createPdf(2),
|
||||
paginationOptions({ freeText: 'AZ 7/26', textScope: 'first', format: 'nummer' })
|
||||
);
|
||||
const document = await PDFDocument.load(output);
|
||||
|
||||
expect(getPageTextValues(document, 0)).toEqual(['AZ 7/26, 1']);
|
||||
expect(getPageTextValues(document, 1)).toEqual(['2']);
|
||||
});
|
||||
|
||||
test('a label that cannot fit at the minimum font size fails loudly', async () => {
|
||||
await expect(
|
||||
paginatePdf(
|
||||
await createPdf(1, [120, 160]),
|
||||
paginationOptions({ freeText: 'x'.repeat(MAX_FREE_TEXT_LENGTH), fontSize: 18 })
|
||||
)
|
||||
).rejects.toBeInstanceOf(TextOverlayError);
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { degrees, PDFDocument, StandardFonts, rgb } from 'pdf-lib';
|
||||
import { TextOverlayError } from '../../src/lib/pdf-errors';
|
||||
import {
|
||||
TEXT_OVERLAY_MARGIN,
|
||||
drawTextOverlay,
|
||||
getRotatedBoxPlacement
|
||||
} from '../../src/lib/pdf-text-overlay';
|
||||
import { getPageText, getPageTextAngle, getPageTextMatrix } from '../e2e/helpers';
|
||||
|
||||
const PAGE_WIDTH = 420;
|
||||
const PAGE_HEIGHT = 594;
|
||||
const MARGIN = 28;
|
||||
|
||||
/**
|
||||
* Maps a point from default user space to the displayed page. Viewers rotate the
|
||||
* content clockwise by `/Rotate`, so this is the forward transformation.
|
||||
*/
|
||||
const toVisualPoint = (
|
||||
point: { x: number; y: number },
|
||||
rotation: number,
|
||||
width: number,
|
||||
height: number
|
||||
) => {
|
||||
switch (rotation) {
|
||||
case 90:
|
||||
return { x: point.y, y: width - point.x };
|
||||
case 180:
|
||||
return { x: width - point.x, y: height - point.y };
|
||||
case 270:
|
||||
return { x: height - point.y, y: point.x };
|
||||
default:
|
||||
return point;
|
||||
}
|
||||
};
|
||||
|
||||
const createPageWithMark = async (rotation: number) => {
|
||||
const document = await PDFDocument.create();
|
||||
const font = await document.embedFont(StandardFonts.Helvetica);
|
||||
const page = document.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
|
||||
page.setRotation(degrees(rotation));
|
||||
drawTextOverlay(page, {
|
||||
font,
|
||||
text: 'Bl. 1',
|
||||
size: 11,
|
||||
color: rgb(0.1, 0.1, 0.1),
|
||||
position: 'bottom-center',
|
||||
margin: MARGIN,
|
||||
compensatePageRotation: true
|
||||
});
|
||||
|
||||
return PDFDocument.load(await document.save());
|
||||
};
|
||||
|
||||
for (const rotation of [0, 90, 180, 270]) {
|
||||
test(`bottom-center mark stays at the visual bottom on a /Rotate ${rotation} page`, async () => {
|
||||
const output = await createPageWithMark(rotation);
|
||||
const matrix = getPageTextMatrix(output, 0);
|
||||
const visualWidth = rotation % 180 === 0 ? PAGE_WIDTH : PAGE_HEIGHT;
|
||||
const origin = toVisualPoint({ x: matrix.x, y: matrix.y }, rotation, PAGE_WIDTH, PAGE_HEIGHT);
|
||||
|
||||
// Horizontally centred on the displayed page.
|
||||
expect(Math.abs(origin.x - visualWidth / 2)).toBeLessThan(30);
|
||||
// Close to the bottom margin (the baseline sits a few points above it).
|
||||
expect(origin.y).toBeGreaterThan(MARGIN - 2);
|
||||
expect(origin.y).toBeLessThan(MARGIN + 20);
|
||||
// The glyphs read horizontally: the drawn angle cancels the page rotation.
|
||||
expect(Math.round(getPageTextAngle(matrix))).toBe(rotation);
|
||||
expect(getPageText(output, 0)).toBe('Bl. 1');
|
||||
});
|
||||
}
|
||||
|
||||
test('a mark that does not fit raises a TextOverlayError instead of shrinking forever', async () => {
|
||||
const document = await PDFDocument.create();
|
||||
const font = await document.embedFont(StandardFonts.Helvetica);
|
||||
const page = document.addPage([120, 160]);
|
||||
|
||||
expect(() =>
|
||||
drawTextOverlay(page, {
|
||||
font,
|
||||
text: 'Ein sehr langer Stempeltext, der nicht auf die Seite passt',
|
||||
size: 18,
|
||||
color: rgb(0, 0, 0),
|
||||
position: 'bottom-center',
|
||||
margin: MARGIN,
|
||||
framed: true
|
||||
})
|
||||
).toThrow(TextOverlayError);
|
||||
});
|
||||
|
||||
test('characters the standard font cannot encode are reported as such', async () => {
|
||||
const document = await PDFDocument.create();
|
||||
const font = await document.embedFont(StandardFonts.Helvetica);
|
||||
const page = document.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
|
||||
|
||||
let thrown: unknown;
|
||||
try {
|
||||
drawTextOverlay(page, {
|
||||
font,
|
||||
text: 'Beschluss \u{1F600}',
|
||||
size: 11,
|
||||
color: rgb(0, 0, 0),
|
||||
position: 'top-right',
|
||||
margin: MARGIN
|
||||
});
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
|
||||
expect(thrown).toBeInstanceOf(TextOverlayError);
|
||||
expect((thrown as Error).message).toContain('nicht dargestellt werden');
|
||||
});
|
||||
|
||||
test('a rotated mark keeps its bounding box inside the page', async () => {
|
||||
const boxWidth = 200;
|
||||
const boxHeight = 40;
|
||||
|
||||
for (const angle of [0, 45, 90, 135]) {
|
||||
const placement = getRotatedBoxPlacement(
|
||||
PAGE_WIDTH,
|
||||
PAGE_HEIGHT,
|
||||
boxWidth,
|
||||
boxHeight,
|
||||
'bottom-center',
|
||||
angle,
|
||||
TEXT_OVERLAY_MARGIN
|
||||
);
|
||||
|
||||
expect(placement.x).toBeGreaterThanOrEqual(TEXT_OVERLAY_MARGIN - 0.001);
|
||||
expect(placement.y).toBeGreaterThanOrEqual(TEXT_OVERLAY_MARGIN - 0.001);
|
||||
expect(placement).not.toHaveProperty('cosine');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import {
|
||||
clearPendingFiles,
|
||||
queuePendingFiles,
|
||||
takePendingFiles
|
||||
} from '../../src/lib/pending-files';
|
||||
|
||||
const pdf = () => new File(['%PDF-1.7'], 'akte.pdf', { type: 'application/pdf' });
|
||||
const zip = () => new File(['PK'], 'archiv.zip', { type: 'application/zip' });
|
||||
|
||||
test.beforeEach(() => clearPendingFiles());
|
||||
|
||||
test('a target only receives the files queued for it', () => {
|
||||
queuePendingFiles([zip()], 'bea');
|
||||
queuePendingFiles([pdf()], 'pagination');
|
||||
|
||||
const paginationFiles = takePendingFiles('pagination');
|
||||
|
||||
expect(paginationFiles.map((file) => file.name)).toEqual(['akte.pdf']);
|
||||
// The beA handoff must survive a pagination visit instead of being dropped.
|
||||
expect(takePendingFiles('bea').map((file) => file.name)).toEqual(['archiv.zip']);
|
||||
});
|
||||
|
||||
test('taking a target clears only that target', () => {
|
||||
queuePendingFiles([pdf(), pdf()], 'pagination');
|
||||
|
||||
expect(takePendingFiles('pagination')).toHaveLength(2);
|
||||
expect(takePendingFiles('pagination')).toHaveLength(0);
|
||||
});
|
||||
Reference in new issue
Block a user