import { degrees, type PDFFont, type PDFPage, type RGB } from 'pdf-lib'; import { getPositionCoords, type OverlayPosition } from './pdf-overlay'; const DEFAULT_MARGIN = 20; const DEFAULT_PADDING = 8; const DEFAULT_MIN_SIZE = 4; const DEFAULT_BORDER_WIDTH = 2; /** Baseline nudge used by the framed text stamps so glyphs sit inside the border. */ const BASELINE_OFFSET_RATIO = 0.2; const DEFAULT_TOO_LONG_MESSAGE = 'Der Text ist für mindestens eine Seite zu lang. Kürzen Sie den Text.'; export type TextOverlayOptions = { font: PDFFont; text: string; size: number; color: RGB; position: OverlayPosition; /** Distance between the mark and the page edge in points. */ margin?: number; /** Inner box padding; use `0` for a frameless, anchor-aligned mark. */ padding?: number; framed?: boolean; borderWidth?: number; /** Additional counter-clockwise rotation of the mark in degrees. */ rotation?: number; /** Font size floor while shrinking to fit the page. */ minSize?: number; /** * Place the mark on rotated scans (`/Rotate 90`, `180`, `270`) where it is * visible instead of sideways. Opt-in so existing tools keep their output. */ compensatePageRotation?: boolean; tooLongMessage?: string; }; type TextBox = { size: number; textHeight: number; boxWidth: number; boxHeight: number; }; const measureText = (font: PDFFont, text: string, size: number) => { try { return { width: font.widthOfTextAtSize(text, size), height: font.heightAtSize(size) }; } catch { throw new Error( 'Der Text enthält Zeichen, die nicht dargestellt werden können. Entfernen Sie z. B. Emoji.' ); } }; const fitTextToPage = ( font: PDFFont, text: string, requestedSize: number, pageWidth: number, pageHeight: number, margin: number, padding: number, minSize: number, tooLongMessage: string ): TextBox => { const availableWidth = pageWidth - margin * 2; const availableHeight = pageHeight - margin * 2; let size = requestedSize; let metrics = measureText(font, text, size); while ( size > minSize && (metrics.width + padding * 2 > availableWidth || metrics.height + padding * 2 > availableHeight) ) { size = Math.max(minSize, size - 0.5); metrics = measureText(font, text, size); } if ( metrics.width + padding * 2 > availableWidth || metrics.height + padding * 2 > availableHeight ) { throw new Error(tooLongMessage); } return { size, textHeight: metrics.height, boxWidth: metrics.width + padding * 2, boxHeight: metrics.height + padding * 2 }; }; /** * Lower-left corner for a (possibly rotated) box inside the given page size. * The bounding box of the rotated mark is aligned to the requested position, so * tilted text never crosses the page edge. */ export const getRotatedBoxPlacement = ( pageWidth: number, pageHeight: number, boxWidth: number, boxHeight: number, position: OverlayPosition, angle: number, margin = DEFAULT_MARGIN ) => { const radians = (angle * Math.PI) / 180; const cosine = Math.cos(radians); const sine = Math.sin(radians); const rotatedCorners = [ { x: 0, y: 0 }, { x: boxWidth * cosine, y: boxWidth * sine }, { x: -boxHeight * sine, y: boxHeight * cosine }, { x: boxWidth * cosine - boxHeight * sine, y: boxWidth * sine + boxHeight * cosine } ]; const minX = Math.min(...rotatedCorners.map((corner) => corner.x)); const maxX = Math.max(...rotatedCorners.map((corner) => corner.x)); const minY = Math.min(...rotatedCorners.map((corner) => corner.y)); const maxY = Math.max(...rotatedCorners.map((corner) => corner.y)); const boundingCoordinates = getPositionCoords( pageWidth, pageHeight, maxX - minX, maxY - minY, position, margin ); return { x: boundingCoordinates.x - minX, y: boundingCoordinates.y - minY, cosine, sine }; }; /** Normalised page rotation; anything that is not a multiple of 90 is ignored. */ const getPageRotation = (page: PDFPage): number => { const angle = Math.round(page.getRotation().angle / 90) * 90; return ((angle % 360) + 360) % 360; }; /** Displayed page size after applying `/Rotate`. */ const getVisualPageSize = (pageWidth: number, pageHeight: number, pageRotation: number) => pageRotation % 180 === 0 ? { width: pageWidth, height: pageHeight } : { width: pageHeight, height: pageWidth }; /** * Maps a point on the displayed page back into default user space. Viewers * rotate page content clockwise by `/Rotate`, so this is the inverse rotation * that keeps a mark at the requested visual position. */ const toUserSpace = ( point: { x: number; y: number }, pageWidth: number, pageHeight: number, pageRotation: number ) => { switch (pageRotation) { case 90: return { x: pageWidth - point.y, y: point.x }; case 180: return { x: pageWidth - point.x, y: pageHeight - point.y }; case 270: return { x: point.y, y: pageHeight - point.x }; default: return point; } }; /** * Draws a single line of text (optionally framed) at one of the standard * positions. Shrinks the font to fit the page and throws a German error instead * of clipping. */ export const drawTextOverlay = (page: PDFPage, options: TextOverlayOptions): void => { const { font, text, size, color, position, margin = DEFAULT_MARGIN, padding = DEFAULT_PADDING, framed = false, borderWidth = DEFAULT_BORDER_WIDTH, rotation = 0, minSize = DEFAULT_MIN_SIZE, compensatePageRotation = false, tooLongMessage = DEFAULT_TOO_LONG_MESSAGE } = options; const { width, height } = page.getSize(); const pageRotation = compensatePageRotation ? getPageRotation(page) : 0; const visual = getVisualPageSize(width, height, pageRotation); const box = fitTextToPage( font, text, size, visual.width, visual.height, margin, padding, minSize, tooLongMessage ); const placement = getRotatedBoxPlacement( visual.width, visual.height, box.boxWidth, box.boxHeight, position, rotation, margin ); const origin = toUserSpace({ x: placement.x, y: placement.y }, width, height, pageRotation); const angle = rotation + pageRotation; const radians = (angle * Math.PI) / 180; const cosine = Math.cos(radians); const sine = Math.sin(radians); const offsetX = padding; const offsetY = padding + box.textHeight * BASELINE_OFFSET_RATIO; if (framed) { page.drawRectangle({ x: origin.x, y: origin.y, width: box.boxWidth, height: box.boxHeight, borderColor: color, borderWidth, rotate: degrees(angle) }); } page.drawText(text, { x: origin.x + offsetX * cosine - offsetY * sine, y: origin.y + offsetX * sine + offsetY * cosine, size: box.size, font, color, rotate: degrees(angle) }); };