mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
During our field rework that makes fields appear more accurately between signing and the completed pdf we swapped to using text fields. Unfortunately as part of that we dropped using the Noto font for the text field causing ANSI encoding issues when encountering certain characters. This change restores the font and handles a nasty issue we had with our form flattening reverting our selected font.
659 lines
21 KiB
TypeScript
659 lines
21 KiB
TypeScript
// https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821
|
|
import fontkit from '@pdf-lib/fontkit';
|
|
import { FieldType } from '@prisma/client';
|
|
import type { PDFDocument, PDFFont, PDFTextField } from 'pdf-lib';
|
|
import {
|
|
RotationTypes,
|
|
TextAlignment,
|
|
degrees,
|
|
radiansToDegrees,
|
|
rgb,
|
|
setFontAndSize,
|
|
} from 'pdf-lib';
|
|
import { P, match } from 'ts-pattern';
|
|
|
|
import {
|
|
DEFAULT_HANDWRITING_FONT_SIZE,
|
|
DEFAULT_STANDARD_FONT_SIZE,
|
|
MIN_HANDWRITING_FONT_SIZE,
|
|
MIN_STANDARD_FONT_SIZE,
|
|
} from '@documenso/lib/constants/pdf';
|
|
import { fromCheckboxValue } from '@documenso/lib/universal/field-checkbox';
|
|
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
|
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
|
|
|
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
|
import {
|
|
ZCheckboxFieldMeta,
|
|
ZDateFieldMeta,
|
|
ZEmailFieldMeta,
|
|
ZInitialsFieldMeta,
|
|
ZNameFieldMeta,
|
|
ZNumberFieldMeta,
|
|
ZRadioFieldMeta,
|
|
ZTextFieldMeta,
|
|
} from '../../types/field-meta';
|
|
|
|
export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignature) => {
|
|
const [fontCaveat, fontNoto] = await Promise.all([
|
|
fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/fonts/caveat.ttf`).then(async (res) => res.arrayBuffer()),
|
|
fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/fonts/noto-sans.ttf`).then(async (res) => res.arrayBuffer()),
|
|
]);
|
|
|
|
const isSignatureField = isSignatureFieldType(field.type);
|
|
|
|
/**
|
|
* Red box is the original field width, height and position.
|
|
*
|
|
* Blue box is the adjusted field width, height and position. It will represent
|
|
* where the text will overflow into.
|
|
*/
|
|
const isDebugMode =
|
|
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
|
process.env.DEBUG_PDF_INSERT === '1' || process.env.DEBUG_PDF_INSERT === 'true';
|
|
|
|
pdf.registerFontkit(fontkit);
|
|
|
|
const pages = pdf.getPages();
|
|
|
|
const minFontSize = isSignatureField ? MIN_HANDWRITING_FONT_SIZE : MIN_STANDARD_FONT_SIZE;
|
|
const maxFontSize = isSignatureField ? DEFAULT_HANDWRITING_FONT_SIZE : DEFAULT_STANDARD_FONT_SIZE;
|
|
|
|
const page = pages.at(field.page - 1);
|
|
|
|
if (!page) {
|
|
throw new Error(`Page ${field.page} does not exist`);
|
|
}
|
|
|
|
const pageRotation = page.getRotation();
|
|
|
|
let pageRotationInDegrees = match(pageRotation.type)
|
|
.with(RotationTypes.Degrees, () => pageRotation.angle)
|
|
.with(RotationTypes.Radians, () => radiansToDegrees(pageRotation.angle))
|
|
.exhaustive();
|
|
|
|
// Round to the closest multiple of 90 degrees.
|
|
pageRotationInDegrees = Math.round(pageRotationInDegrees / 90) * 90;
|
|
|
|
const isPageRotatedToLandscape = pageRotationInDegrees === 90 || pageRotationInDegrees === 270;
|
|
|
|
let { width: pageWidth, height: pageHeight } = page.getSize();
|
|
|
|
// PDFs can have pages that are rotated, which are correctly rendered in the frontend.
|
|
// However when we load the PDF in the backend, the rotation is applied.
|
|
//
|
|
// To account for this, we swap the width and height for pages that are rotated by 90/270
|
|
// degrees. This is so we can calculate the virtual position the field was placed if it
|
|
// was correctly oriented in the frontend.
|
|
//
|
|
// Then when we insert the fields, we apply a transformation to the position of the field
|
|
// so it is rotated correctly.
|
|
if (isPageRotatedToLandscape) {
|
|
[pageWidth, pageHeight] = [pageHeight, pageWidth];
|
|
}
|
|
|
|
const fieldWidth = pageWidth * (Number(field.width) / 100);
|
|
const fieldHeight = pageHeight * (Number(field.height) / 100);
|
|
|
|
const fieldX = pageWidth * (Number(field.positionX) / 100);
|
|
const fieldY = pageHeight * (Number(field.positionY) / 100);
|
|
|
|
// Draw debug box if debug mode is enabled
|
|
if (isDebugMode) {
|
|
let debugX = fieldX;
|
|
let debugY = pageHeight - fieldY - fieldHeight; // Invert Y for PDF coordinates
|
|
|
|
if (pageRotationInDegrees !== 0) {
|
|
const adjustedPosition = adjustPositionForRotation(
|
|
pageWidth,
|
|
pageHeight,
|
|
debugX,
|
|
debugY,
|
|
pageRotationInDegrees,
|
|
);
|
|
|
|
debugX = adjustedPosition.xPos;
|
|
debugY = adjustedPosition.yPos;
|
|
}
|
|
|
|
page.drawRectangle({
|
|
x: debugX,
|
|
y: debugY,
|
|
width: fieldWidth,
|
|
height: fieldHeight,
|
|
borderColor: rgb(1, 0, 0), // Red
|
|
borderWidth: 1,
|
|
rotate: degrees(pageRotationInDegrees),
|
|
});
|
|
}
|
|
|
|
const font = await pdf.embedFont(
|
|
isSignatureField ? fontCaveat : fontNoto,
|
|
isSignatureField ? { features: { calt: false } } : undefined,
|
|
);
|
|
|
|
if (field.type === FieldType.SIGNATURE || field.type === FieldType.FREE_SIGNATURE) {
|
|
await pdf.embedFont(fontCaveat);
|
|
}
|
|
|
|
await match(field)
|
|
.with(
|
|
{
|
|
type: P.union(FieldType.SIGNATURE, FieldType.FREE_SIGNATURE),
|
|
},
|
|
async (field) => {
|
|
if (field.signature?.signatureImageAsBase64) {
|
|
const image = await pdf.embedPng(field.signature?.signatureImageAsBase64 ?? '');
|
|
|
|
let imageWidth = image.width;
|
|
let imageHeight = image.height;
|
|
|
|
const scalingFactor = Math.min(fieldWidth / imageWidth, fieldHeight / imageHeight, 1);
|
|
|
|
imageWidth = imageWidth * scalingFactor;
|
|
imageHeight = imageHeight * scalingFactor;
|
|
|
|
let imageX = fieldX + (fieldWidth - imageWidth) / 2;
|
|
let imageY = fieldY + (fieldHeight - imageHeight) / 2;
|
|
|
|
// Invert the Y axis since PDFs use a bottom-left coordinate system
|
|
imageY = pageHeight - imageY - imageHeight;
|
|
|
|
if (pageRotationInDegrees !== 0) {
|
|
const adjustedPosition = adjustPositionForRotation(
|
|
pageWidth,
|
|
pageHeight,
|
|
imageX,
|
|
imageY,
|
|
pageRotationInDegrees,
|
|
);
|
|
|
|
imageX = adjustedPosition.xPos;
|
|
imageY = adjustedPosition.yPos;
|
|
}
|
|
|
|
page.drawImage(image, {
|
|
x: imageX,
|
|
y: imageY,
|
|
width: imageWidth,
|
|
height: imageHeight,
|
|
rotate: degrees(pageRotationInDegrees),
|
|
});
|
|
} else {
|
|
const signatureText = field.signature?.typedSignature ?? '';
|
|
|
|
const longestLineInTextForWidth = signatureText
|
|
.split('\n')
|
|
.sort((a, b) => b.length - a.length)[0];
|
|
|
|
let fontSize = maxFontSize;
|
|
let textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
|
|
let textHeight = font.heightAtSize(fontSize);
|
|
|
|
const scalingFactor = Math.min(fieldWidth / textWidth, fieldHeight / textHeight, 1);
|
|
fontSize = Math.max(Math.min(fontSize * scalingFactor, maxFontSize), minFontSize);
|
|
|
|
textWidth = font.widthOfTextAtSize(longestLineInTextForWidth, fontSize);
|
|
textHeight = font.heightAtSize(fontSize);
|
|
|
|
let textX = fieldX + (fieldWidth - textWidth) / 2;
|
|
let textY = fieldY + (fieldHeight - textHeight) / 2;
|
|
|
|
// Invert the Y axis since PDFs use a bottom-left coordinate system
|
|
textY = pageHeight - textY - textHeight;
|
|
|
|
if (pageRotationInDegrees !== 0) {
|
|
const adjustedPosition = adjustPositionForRotation(
|
|
pageWidth,
|
|
pageHeight,
|
|
textX,
|
|
textY,
|
|
pageRotationInDegrees,
|
|
);
|
|
|
|
textX = adjustedPosition.xPos;
|
|
textY = adjustedPosition.yPos;
|
|
}
|
|
|
|
page.drawText(signatureText, {
|
|
x: textX,
|
|
y: textY,
|
|
size: fontSize,
|
|
font,
|
|
rotate: degrees(pageRotationInDegrees),
|
|
});
|
|
}
|
|
},
|
|
)
|
|
.with({ type: FieldType.CHECKBOX }, (field) => {
|
|
const meta = ZCheckboxFieldMeta.safeParse(field.fieldMeta);
|
|
|
|
if (!meta.success) {
|
|
console.error(meta.error);
|
|
|
|
throw new Error('Invalid checkbox field meta');
|
|
}
|
|
|
|
const values = meta.data.values?.map((item) => ({
|
|
...item,
|
|
value: item.value.length > 0 ? item.value : `empty-value-${item.id}`,
|
|
}));
|
|
|
|
const selected: string[] = fromCheckboxValue(field.customText);
|
|
|
|
const topPadding = 12;
|
|
const leftCheckboxPadding = 8;
|
|
const leftCheckboxLabelPadding = 12;
|
|
const checkboxSpaceY = 13;
|
|
|
|
for (const [index, item] of (values ?? []).entries()) {
|
|
const offsetY = index * checkboxSpaceY + topPadding;
|
|
|
|
const checkbox = pdf.getForm().createCheckBox(`checkbox.${field.secondaryId}.${index}`);
|
|
|
|
if (selected.includes(item.value)) {
|
|
checkbox.check();
|
|
}
|
|
|
|
page.drawText(item.value.includes('empty-value-') ? '' : item.value, {
|
|
x: fieldX + leftCheckboxPadding + leftCheckboxLabelPadding,
|
|
y: pageHeight - (fieldY + offsetY),
|
|
size: 12,
|
|
font,
|
|
rotate: degrees(pageRotationInDegrees),
|
|
});
|
|
|
|
checkbox.addToPage(page, {
|
|
x: fieldX + leftCheckboxPadding,
|
|
y: pageHeight - (fieldY + offsetY),
|
|
height: 8,
|
|
width: 8,
|
|
});
|
|
}
|
|
})
|
|
.with({ type: FieldType.RADIO }, (field) => {
|
|
const meta = ZRadioFieldMeta.safeParse(field.fieldMeta);
|
|
|
|
if (!meta.success) {
|
|
console.error(meta.error);
|
|
|
|
throw new Error('Invalid radio field meta');
|
|
}
|
|
|
|
const values = meta?.data.values?.map((item) => ({
|
|
...item,
|
|
value: item.value.length > 0 ? item.value : `empty-value-${item.id}`,
|
|
}));
|
|
|
|
const selected = field.customText.split(',');
|
|
|
|
const topPadding = 12;
|
|
const leftRadioPadding = 8;
|
|
const leftRadioLabelPadding = 12;
|
|
const radioSpaceY = 13;
|
|
|
|
for (const [index, item] of (values ?? []).entries()) {
|
|
const offsetY = index * radioSpaceY + topPadding;
|
|
|
|
const radio = pdf.getForm().createRadioGroup(`radio.${field.secondaryId}.${index}`);
|
|
|
|
// Draw label.
|
|
page.drawText(item.value.includes('empty-value-') ? '' : item.value, {
|
|
x: fieldX + leftRadioPadding + leftRadioLabelPadding,
|
|
y: pageHeight - (fieldY + offsetY),
|
|
size: 12,
|
|
font,
|
|
rotate: degrees(pageRotationInDegrees),
|
|
});
|
|
|
|
// Draw radio button.
|
|
radio.addOptionToPage(item.value, page, {
|
|
x: fieldX + leftRadioPadding,
|
|
y: pageHeight - (fieldY + offsetY),
|
|
height: 8,
|
|
width: 8,
|
|
});
|
|
|
|
if (selected.includes(item.value)) {
|
|
radio.select(item.value);
|
|
}
|
|
}
|
|
})
|
|
.otherwise((field) => {
|
|
const fieldMetaParsers = {
|
|
[FieldType.TEXT]: ZTextFieldMeta,
|
|
[FieldType.NUMBER]: ZNumberFieldMeta,
|
|
[FieldType.DATE]: ZDateFieldMeta,
|
|
[FieldType.EMAIL]: ZEmailFieldMeta,
|
|
[FieldType.NAME]: ZNameFieldMeta,
|
|
[FieldType.INITIALS]: ZInitialsFieldMeta,
|
|
} as const;
|
|
|
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
|
const fieldMetaParser = fieldMetaParsers[field.type as keyof typeof fieldMetaParsers];
|
|
const meta = fieldMetaParser ? fieldMetaParser.safeParse(field.fieldMeta) : null;
|
|
|
|
const customFontSize = meta?.success && meta.data.fontSize ? meta.data.fontSize : null;
|
|
const textAlign = meta?.success && meta.data.textAlign ? meta.data.textAlign : 'left';
|
|
|
|
let fontSize = customFontSize || maxFontSize;
|
|
const textWidth = font.widthOfTextAtSize(field.customText, fontSize);
|
|
const textHeight = font.heightAtSize(fontSize);
|
|
|
|
// Scale font only if no custom font and height exceeds field height.
|
|
if (!customFontSize) {
|
|
const scalingFactor = Math.min(fieldHeight / textHeight, 1);
|
|
fontSize = Math.max(Math.min(fontSize * scalingFactor, maxFontSize), minFontSize);
|
|
}
|
|
|
|
/**
|
|
* Calculate whether the field should be multiline.
|
|
*
|
|
* - True = text will overflow downwards.
|
|
* - False = text will overflow sideways.
|
|
*/
|
|
const isMultiline =
|
|
field.type === FieldType.TEXT &&
|
|
(textWidth > fieldWidth || field.customText.includes('\n'));
|
|
|
|
// Add padding similar to web display (roughly 0.5rem equivalent in PDF units)
|
|
const padding = 8;
|
|
|
|
const textAlignmentOptions = getTextAlignmentOptions(textAlign, fieldX, isMultiline, padding);
|
|
|
|
// Invert the Y axis since PDFs use a bottom-left coordinate system
|
|
let textFieldBoxY = pageHeight - fieldY - fieldHeight;
|
|
const textFieldBoxX = textAlignmentOptions.xPos;
|
|
|
|
const textField = pdf.getForm().createTextField(`text.${field.secondaryId}`);
|
|
textField.setAlignment(textAlignmentOptions.textAlignment);
|
|
|
|
/**
|
|
* From now on we will adjust the field size and position so the text
|
|
* overflows correctly in the X or Y axis depending on the field type.
|
|
*/
|
|
let adjustedFieldWidth = fieldWidth - padding * 2; //
|
|
let adjustedFieldHeight = fieldHeight;
|
|
let adjustedFieldX = textFieldBoxX;
|
|
let adjustedFieldY = textFieldBoxY;
|
|
|
|
let textToInsert = field.customText;
|
|
|
|
// The padding to use when fields go off the page.
|
|
const pagePadding = 4;
|
|
|
|
// Handle multiline text, which will overflow on the Y axis.
|
|
if (isMultiline) {
|
|
textToInsert = breakLongString(textToInsert, adjustedFieldWidth, font, fontSize);
|
|
|
|
textField.enableMultiline();
|
|
textField.disableCombing();
|
|
textField.disableScrolling();
|
|
|
|
// Adjust the textFieldBox so it extends to the bottom of the page so text can wrap.
|
|
textFieldBoxY = pageHeight - fieldY - fieldHeight;
|
|
|
|
// Calculate how much PX from the current field to bottom of the page.
|
|
const fieldYOffset = pageHeight - (fieldY + fieldHeight) - pagePadding;
|
|
|
|
// Field height will be from current to bottom of page.
|
|
adjustedFieldHeight = fieldHeight + fieldYOffset;
|
|
|
|
// Need to move the field Y so it offsets the new field height.
|
|
adjustedFieldY = adjustedFieldY - fieldYOffset;
|
|
}
|
|
|
|
// Handle non-multiline text, which will overflow on the X axis.
|
|
if (!isMultiline) {
|
|
// Left align will extend all the way to the right of the page
|
|
if (textAlignmentOptions.textAlignment === TextAlignment.Left) {
|
|
adjustedFieldWidth = pageWidth - textFieldBoxX - pagePadding;
|
|
}
|
|
|
|
// Right align will extend all the way to the left of the page.
|
|
if (textAlignmentOptions.textAlignment === TextAlignment.Right) {
|
|
adjustedFieldWidth = textFieldBoxX + fieldWidth - pagePadding;
|
|
adjustedFieldX = adjustedFieldX - adjustedFieldWidth + fieldWidth;
|
|
}
|
|
|
|
// Center align will extend to the closest page edge, then use that * 2 as the width.
|
|
if (textAlignmentOptions.textAlignment === TextAlignment.Center) {
|
|
const fieldMidpoint = textFieldBoxX + fieldWidth / 2;
|
|
|
|
const isCloserToLeftEdge = fieldMidpoint < pageWidth / 2;
|
|
|
|
// If field is closer to left edge, the width must be based of the left.
|
|
if (isCloserToLeftEdge) {
|
|
adjustedFieldWidth = (textFieldBoxX - pagePadding) * 2 + fieldWidth;
|
|
adjustedFieldX = pagePadding;
|
|
}
|
|
|
|
// If field is closer to right edge, the width must be based of the right
|
|
if (!isCloserToLeftEdge) {
|
|
adjustedFieldWidth = (pageWidth - textFieldBoxX - pagePadding - fieldWidth / 2) * 2;
|
|
adjustedFieldX = pageWidth - adjustedFieldWidth - pagePadding;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (pageRotationInDegrees !== 0) {
|
|
const adjustedPosition = adjustPositionForRotation(
|
|
pageWidth,
|
|
pageHeight,
|
|
adjustedFieldX,
|
|
adjustedFieldY,
|
|
pageRotationInDegrees,
|
|
);
|
|
|
|
adjustedFieldX = adjustedPosition.xPos;
|
|
adjustedFieldY = adjustedPosition.yPos;
|
|
}
|
|
|
|
// Set properties for the text field
|
|
setTextFieldFontSize(textField, font, fontSize);
|
|
textField.setText(textToInsert);
|
|
|
|
// Set the position and size of the text field
|
|
textField.addToPage(page, {
|
|
x: adjustedFieldX,
|
|
y: adjustedFieldY,
|
|
width: adjustedFieldWidth,
|
|
height: adjustedFieldHeight,
|
|
rotate: degrees(pageRotationInDegrees),
|
|
|
|
font,
|
|
|
|
// Hide borders.
|
|
borderWidth: 0,
|
|
borderColor: undefined,
|
|
backgroundColor: undefined,
|
|
|
|
...(isDebugMode ? { borderWidth: 1, borderColor: rgb(0, 0, 1) } : {}),
|
|
});
|
|
});
|
|
|
|
return pdf;
|
|
};
|
|
|
|
const adjustPositionForRotation = (
|
|
pageWidth: number,
|
|
pageHeight: number,
|
|
xPos: number,
|
|
yPos: number,
|
|
pageRotationInDegrees: number,
|
|
) => {
|
|
if (pageRotationInDegrees === 270) {
|
|
xPos = pageWidth - xPos;
|
|
[xPos, yPos] = [yPos, xPos];
|
|
}
|
|
|
|
if (pageRotationInDegrees === 90) {
|
|
yPos = pageHeight - yPos;
|
|
[xPos, yPos] = [yPos, xPos];
|
|
}
|
|
|
|
// Invert all the positions since it's rotated by 180 degrees.
|
|
if (pageRotationInDegrees === 180) {
|
|
xPos = pageWidth - xPos;
|
|
yPos = pageHeight - yPos;
|
|
}
|
|
|
|
return {
|
|
xPos,
|
|
yPos,
|
|
};
|
|
};
|
|
|
|
const textAlignmentMap = {
|
|
left: TextAlignment.Left,
|
|
center: TextAlignment.Center,
|
|
right: TextAlignment.Right,
|
|
} as const;
|
|
|
|
/**
|
|
* Get the PDF-lib alignment position, and the X position of the field with padding included.
|
|
*
|
|
* @param textAlign - The text alignment of the field.
|
|
* @param fieldX - The X position of the field.
|
|
* @param isMultiline - Whether the field is multiline.
|
|
* @param padding - The padding of the field. Defaults to 8.
|
|
*
|
|
* @returns The X position and text alignment for the field.
|
|
*/
|
|
const getTextAlignmentOptions = (
|
|
textAlign: 'left' | 'center' | 'right',
|
|
fieldX: number,
|
|
isMultiline: boolean,
|
|
padding: number = 8,
|
|
) => {
|
|
const textAlignment = textAlignmentMap[textAlign];
|
|
|
|
// For multiline, it needs to be centered so we just basic left padding.
|
|
if (isMultiline) {
|
|
return {
|
|
xPos: fieldX + padding,
|
|
textAlignment,
|
|
};
|
|
}
|
|
|
|
return match(textAlign)
|
|
.with('left', () => ({
|
|
xPos: fieldX + padding,
|
|
textAlignment,
|
|
}))
|
|
.with('center', () => ({
|
|
xPos: fieldX,
|
|
textAlignment,
|
|
}))
|
|
.with('right', () => ({
|
|
xPos: fieldX - padding,
|
|
textAlignment,
|
|
}))
|
|
.exhaustive();
|
|
};
|
|
|
|
/**
|
|
* Break a long string into multiple lines so it fits within a given width,
|
|
* using natural word breaking similar to word processors.
|
|
*
|
|
* - Keeps words together when possible
|
|
* - Only breaks words when they're too long to fit on a line
|
|
* - Handles whitespace intelligently
|
|
*
|
|
* @param text - The text to break into lines
|
|
* @param maxWidth - The maximum width of each line in PX
|
|
* @param font - The PDF font object
|
|
* @param fontSize - The font size in points
|
|
* @returns Object containing the result string and line count
|
|
*/
|
|
function breakLongString(text: string, maxWidth: number, font: PDFFont, fontSize: number): string {
|
|
// Handle empty text
|
|
if (!text) {
|
|
return '';
|
|
}
|
|
|
|
const lines: string[] = [];
|
|
|
|
// Process each original line separately to preserve newlines
|
|
for (const paragraph of text.split('\n')) {
|
|
// If paragraph fits on one line or is empty, add it as-is
|
|
if (paragraph === '' || font.widthOfTextAtSize(paragraph, fontSize) <= maxWidth) {
|
|
lines.push(paragraph);
|
|
continue;
|
|
}
|
|
|
|
// Split paragraph into words
|
|
const words = paragraph.split(' ');
|
|
let currentLine = '';
|
|
|
|
for (const word of words) {
|
|
// Check if adding word to current line would exceed max width
|
|
const lineWithWord = currentLine.length === 0 ? word : `${currentLine} ${word}`;
|
|
|
|
if (font.widthOfTextAtSize(lineWithWord, fontSize) <= maxWidth) {
|
|
// Word fits, add it to current line
|
|
currentLine = lineWithWord;
|
|
} else {
|
|
// Word doesn't fit on current line
|
|
|
|
// First, save current line if it's not empty
|
|
if (currentLine.length > 0) {
|
|
lines.push(currentLine);
|
|
currentLine = '';
|
|
}
|
|
|
|
// Check if word fits on a line by itself
|
|
if (font.widthOfTextAtSize(word, fontSize) <= maxWidth) {
|
|
// Word fits on its own line
|
|
currentLine = word;
|
|
} else {
|
|
// Word is too long, need to break it character by character
|
|
let charLine = '';
|
|
|
|
// Process each character in the word
|
|
for (const char of word) {
|
|
const nextCharLine = charLine + char;
|
|
|
|
if (font.widthOfTextAtSize(nextCharLine, fontSize) <= maxWidth) {
|
|
// Character fits, add it
|
|
charLine = nextCharLine;
|
|
} else {
|
|
// Character doesn't fit, push current charLine and start a new one
|
|
lines.push(charLine);
|
|
charLine = char;
|
|
}
|
|
}
|
|
|
|
// Add any remaining characters as the current line
|
|
currentLine = charLine;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add the last line if not empty
|
|
if (currentLine.length > 0) {
|
|
lines.push(currentLine);
|
|
}
|
|
}
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
const setTextFieldFontSize = (textField: PDFTextField, font: PDFFont, fontSize: number) => {
|
|
textField.defaultUpdateAppearances(font);
|
|
textField.updateAppearances(font);
|
|
|
|
try {
|
|
textField.setFontSize(fontSize);
|
|
} catch (err) {
|
|
let da = textField.acroField.getDefaultAppearance() ?? '';
|
|
|
|
da += `\n ${setFontAndSize(font.name, fontSize)}`;
|
|
|
|
textField.acroField.setDefaultAppearance(da);
|
|
}
|
|
|
|
textField.defaultUpdateAppearances(font);
|
|
textField.updateAppearances(font);
|
|
};
|