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'); } });