249 lines
6.5 KiB
TypeScript
249 lines
6.5 KiB
TypeScript
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)
|
|
});
|
|
};
|