feat: add new field overflow methods (#2715)

This commit is contained in:
David Nguyen
2026-05-08 15:14:27 +10:00
committed by GitHub
parent 4877d1964a
commit 207135d6f3
28 changed files with 2437 additions and 65 deletions
@@ -7,6 +7,7 @@ import type { z } from 'zod';
import {
DEFAULT_FIELD_FONT_SIZE,
type TDateFieldMeta as DateFieldMeta,
FIELD_DATE_META_DEFAULT_VALUES,
FIELD_DEFAULT_GENERIC_ALIGN,
ZDateFieldMeta,
} from '@documenso/lib/types/field-meta';
@@ -20,12 +21,13 @@ import {
const ZDateFieldFormSchema = ZDateFieldMeta.pick({
fontSize: true,
textAlign: true,
overflow: true,
});
type TDateFieldFormSchema = z.infer<typeof ZDateFieldFormSchema>;
type EditorFieldDateFormProps = {
value: DateFieldMeta | undefined;
value: z.input<typeof ZDateFieldMeta> | undefined;
onValueChange: (value: DateFieldMeta) => void;
};
@@ -41,6 +43,7 @@ export const EditorFieldDateForm = ({
defaultValues: {
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
textAlign: value.textAlign ?? FIELD_DEFAULT_GENERIC_ALIGN,
overflow: value.overflow || FIELD_DATE_META_DEFAULT_VALUES.overflow,
},
});
@@ -8,6 +8,7 @@ import {
DEFAULT_FIELD_FONT_SIZE,
type TEmailFieldMeta as EmailFieldMeta,
FIELD_DEFAULT_GENERIC_ALIGN,
FIELD_EMAIL_META_DEFAULT_VALUES,
ZEmailFieldMeta,
} from '@documenso/lib/types/field-meta';
import { Form } from '@documenso/ui/primitives/form/form';
@@ -20,12 +21,13 @@ import {
const ZEmailFieldFormSchema = ZEmailFieldMeta.pick({
fontSize: true,
textAlign: true,
overflow: true,
});
type TEmailFieldFormSchema = z.infer<typeof ZEmailFieldFormSchema>;
type EditorFieldEmailFormProps = {
value: EmailFieldMeta | undefined;
value: z.input<typeof ZEmailFieldMeta> | undefined;
onValueChange: (value: EmailFieldMeta) => void;
};
@@ -41,6 +43,7 @@ export const EditorFieldEmailForm = ({
defaultValues: {
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
textAlign: value.textAlign ?? FIELD_DEFAULT_GENERIC_ALIGN,
overflow: value.overflow || FIELD_EMAIL_META_DEFAULT_VALUES.overflow,
},
});
@@ -6,19 +6,24 @@ import { useForm, useWatch } from 'react-hook-form';
import type { z } from 'zod';
import { DEFAULT_SIGNATURE_TEXT_FONT_SIZE } from '@documenso/lib/constants/pdf';
import { type TSignatureFieldMeta, ZSignatureFieldMeta } from '@documenso/lib/types/field-meta';
import {
FIELD_SIGNATURE_META_DEFAULT_VALUES,
type TSignatureFieldMeta,
ZSignatureFieldMeta,
} from '@documenso/lib/types/field-meta';
import { Form } from '@documenso/ui/primitives/form/form';
import { EditorGenericFontSizeField } from './editor-field-generic-field-forms';
const ZSignatureFieldFormSchema = ZSignatureFieldMeta.pick({
fontSize: true,
overflow: true,
});
type TSignatureFieldFormSchema = z.infer<typeof ZSignatureFieldFormSchema>;
type EditorFieldSignatureFormProps = {
value: TSignatureFieldMeta | undefined;
value: z.input<typeof ZSignatureFieldMeta> | undefined;
onValueChange: (value: TSignatureFieldMeta) => void;
};
@@ -32,6 +37,7 @@ export const EditorFieldSignatureForm = ({
resolver: zodResolver(ZSignatureFieldFormSchema),
mode: 'onChange',
defaultValues: {
overflow: value.overflow || FIELD_SIGNATURE_META_DEFAULT_VALUES.overflow,
fontSize: value.fontSize || DEFAULT_SIGNATURE_TEXT_FONT_SIZE,
},
});
@@ -60,7 +66,7 @@ export const EditorFieldSignatureForm = ({
<fieldset className="flex flex-col gap-2">
<div>
<EditorGenericFontSizeField formControl={form.control} />
<p className="text-muted-foreground mt-0.5 text-xs">
<p className="mt-0.5 text-xs text-muted-foreground">
<Trans>The typed signature font size</Trans>
</p>
</div>
@@ -167,7 +167,9 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
const currentTarget = e.currentTarget as Konva.Group;
const target = e.target as Konva.Shape;
const { width: fieldWidth, height: fieldHeight } = fieldGroup.getClientRect();
const fieldRect = fieldGroup.findOne('.field-rect');
const fieldWidth = fieldRect ? fieldRect.width() : fieldGroup.width();
const fieldHeight = fieldRect ? fieldRect.height() : fieldGroup.height();
const foundField = localPageFields.find((f) => f.id === unparsedField.id);
const foundLoadingGroup = currentTarget.findOne('.loading-spinner-group');
@@ -195,8 +197,8 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
}
const loadingSpinnerGroup = createSpinner({
fieldWidth: fieldWidth / scale,
fieldHeight: fieldHeight / scale,
fieldWidth,
fieldHeight,
});
const parsedFoundField = ZFullFieldSchema.parse(foundField);
Binary file not shown.
@@ -86,6 +86,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
fontSize: 10,
textAlign: 'left',
type: 'email',
overflow: 'auto',
},
page: 1,
...calculatePositionPageOne(0, 0),
@@ -96,6 +97,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
fieldMeta: {
textAlign: 'center',
type: 'email',
overflow: 'auto',
},
page: 1,
...calculatePositionPageOne(0, 1),
@@ -107,6 +109,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
fontSize: 20,
textAlign: 'right',
type: 'email',
overflow: 'auto',
},
page: 1,
...calculatePositionPageOne(0, 2),
@@ -156,6 +159,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
fontSize: 10,
textAlign: 'left',
type: 'date',
overflow: 'auto',
},
page: 1,
...calculatePositionPageOne(2, 0),
@@ -166,6 +170,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
fieldMeta: {
textAlign: 'center',
type: 'date',
overflow: 'auto',
},
page: 1,
...calculatePositionPageOne(2, 1),
@@ -177,6 +182,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
fontSize: 20,
textAlign: 'right',
type: 'date',
overflow: 'auto',
},
page: 1,
...calculatePositionPageOne(2, 2),
@@ -424,6 +430,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
fieldMeta: {
fontSize: 10,
type: 'signature',
overflow: 'auto',
},
page: 1,
...calculatePositionPageOne(9, 0),
@@ -434,6 +441,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: FieldType.SIGNATURE,
fieldMeta: {
type: 'signature',
overflow: 'auto',
},
page: 1,
...calculatePositionPageOne(9, 1),
@@ -445,6 +453,7 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
fieldMeta: {
fontSize: 20,
type: 'signature',
overflow: 'auto',
},
page: 1,
...calculatePositionPageOne(9, 2),
@@ -0,0 +1,790 @@
import { FieldType } from '@prisma/client';
import type { FieldTestData } from './field-alignment-pdf';
/**
* Overflow test data extends FieldTestData with a `seedFieldMeta` property.
*
* - `fieldMeta`: Minimal field meta sent via the API. Omit properties that the API
* auto-applies via ZEnvelopeFieldAndMetaSchema defaults (e.g. `overflow: 'auto'` for
* date/email/signature fields). This tests that the API correctly sets defaults.
*
* - `seedFieldMeta`: Full field meta written directly to the DB by the seed function.
* Must include ALL properties explicitly since the seed bypasses API validation/defaults.
* Used by `seedOverflowTestDocument` in initial-seed.ts.
*/
export type OverflowFieldTestData = Omit<FieldTestData, 'fieldMeta'> & {
fieldMeta?: FieldTestData['fieldMeta'];
seedFieldMeta: FieldTestData['fieldMeta'];
};
const SINGLE_LINE_HEIGHT = 1.75;
const MULTI_LINE_HEIGHT = 12;
const DEFAULT_BOX_WIDTH = 25;
const SINGLE_TYPE_BOX_WIDTH = 35;
const DEFAULT_START_X = 10;
/**
* Pages 1-3: Date, Email, Signature
* Single-line section (rows 0-2): single column, full width
* Pages 1-2 multi-line: 3×3 grid (rows = TA_LEFT/CENTER/RIGHT, columns = short/medium/long text)
* Page 3 multi-line: stacked single column (signature has no text align control)
*/
const SINGLE_TYPE_ML_COLUMN_X = [2.5, 35, 67.5];
const SINGLE_TYPE_ML_BOX_WIDTH = 30;
const SINGLE_TYPE_ML_ROW_Y = [45, 63, 83];
const calculateSingleLinePosition = (row: number) => {
const singleLineYPositions = [15, 23, 31];
return {
positionX: DEFAULT_START_X,
positionY: singleLineYPositions[row],
width: SINGLE_TYPE_BOX_WIDTH,
height: SINGLE_LINE_HEIGHT,
};
};
/** Pages 1-2: multi-line 3×3 grid */
const calculateMultiLinePosition = (row: number, column: number) => {
return {
positionX: SINGLE_TYPE_ML_COLUMN_X[column],
positionY: SINGLE_TYPE_ML_ROW_Y[row],
width: SINGLE_TYPE_ML_BOX_WIDTH,
height: MULTI_LINE_HEIGHT,
};
};
/** Page 3: multi-line stacked single column */
const calculateStackedMultiLinePosition = (row: number) => {
const yPositions = [45, 63, 81];
return {
positionX: DEFAULT_START_X,
positionY: yPositions[row],
width: SINGLE_TYPE_BOX_WIDTH,
height: MULTI_LINE_HEIGHT,
};
};
/**
* Pages 4-5: Text Auto Mode (3x3 grid)
*/
const TEXT_AUTO_COLUMN_X = [5, 35.5, 66];
const TEXT_AUTO_BOX_WIDTH = 28;
const calculateTextAutoPosition = (row: number, column: number, isSingleLine: boolean) => {
if (isSingleLine) {
// Single-line: all 9 items evenly spaced down the page.
// Order: row0-col0, row0-col1, row0-col2, row1-col0, ...
const startY = 10;
const endY = 92;
const spacing = (endY - startY) / 8; // 9 items, 8 gaps = 10.25%
const itemIndex = row * 3 + column;
return {
positionX: TEXT_AUTO_COLUMN_X[column],
positionY: startY + itemIndex * spacing,
width: TEXT_AUTO_BOX_WIDTH,
height: SINGLE_LINE_HEIGHT,
};
}
// Multi-line: 3 rows evenly spaced, bottom row near page bottom.
// Box is 12% tall. Top of last box at 80% so bottom edge is at 92%.
const multiLineYPositions = [10, 45, 80];
return {
positionX: TEXT_AUTO_COLUMN_X[column],
positionY: multiLineYPositions[row],
width: TEXT_AUTO_BOX_WIDTH,
height: MULTI_LINE_HEIGHT,
};
};
/**
* Page 6: Explicit Modes
*/
const HORIZONTAL_CENTERED_X = (100 - DEFAULT_BOX_WIDTH) / 2; // 37.5%
const calculateExplicitHorizontalPosition = (row: number) => {
const yPositions = [15, 21, 27];
return {
positionX: HORIZONTAL_CENTERED_X,
positionY: yPositions[row],
width: DEFAULT_BOX_WIDTH,
height: SINGLE_LINE_HEIGHT,
};
};
const calculateExplicitVerticalPosition = (column: number) => {
const xPositions = [5, 37.5, 70];
return {
positionX: xPositions[column],
positionY: 43,
width: DEFAULT_BOX_WIDTH,
height: MULTI_LINE_HEIGHT,
};
};
export const OVERFLOW_TEST_FIELDS: OverflowFieldTestData[] = [
/**
* @@@@@@@@@@@@@@@@@@@@@@@
*
* PAGE 1: DATE OVERFLOW
*
* @@@@@@@@@@@@@@@@@@@@@@@
*/
// Single-line: Row 0-2 (default date meta — API auto-adds overflow: 'auto')
{
type: FieldType.DATE,
fieldMeta: undefined,
seedFieldMeta: { type: 'date', overflow: 'auto' },
page: 1,
...calculateSingleLinePosition(0),
customText: 'Apr 16 2026',
},
{
type: FieldType.DATE,
fieldMeta: undefined,
seedFieldMeta: { type: 'date', overflow: 'auto' },
page: 1,
...calculateSingleLinePosition(1),
customText: 'Wednesday, April 16, 2026 at 14:30:45 UTC',
},
{
type: FieldType.DATE,
fieldMeta: undefined,
seedFieldMeta: { type: 'date', overflow: 'auto' },
page: 1,
...calculateSingleLinePosition(2),
customText:
'Wednesday, April 16, 2026 at 14:30:45.123 Coordinated Universal Time signed in Melbourne, Australia',
},
// Multi-line 3×3: Row 0 = TA_LEFT (short / medium / long)
{
type: FieldType.DATE,
fieldMeta: { type: 'date', textAlign: 'left' },
seedFieldMeta: { type: 'date', overflow: 'auto', textAlign: 'left' },
page: 1,
...calculateMultiLinePosition(0, 0),
customText: 'Apr 16 2026',
},
{
type: FieldType.DATE,
fieldMeta: { type: 'date', textAlign: 'left' },
seedFieldMeta: { type: 'date', overflow: 'auto', textAlign: 'left' },
page: 1,
...calculateMultiLinePosition(0, 1),
customText: 'Wednesday, April 16, 2026 at 14:30:45 Coordinated Universal Time',
},
{
type: FieldType.DATE,
fieldMeta: { type: 'date', textAlign: 'left' },
seedFieldMeta: { type: 'date', overflow: 'auto', textAlign: 'left' },
page: 1,
...calculateMultiLinePosition(0, 2),
customText:
'Wednesday, April 16, 2026 at 14:30:45.123 Coordinated Universal Time signed in Melbourne, Australia. Count to 20, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty',
},
// Multi-line 3×3: Row 1 = TA_CENTER (short / medium / long)
{
type: FieldType.DATE,
fieldMeta: { type: 'date', textAlign: 'center' },
seedFieldMeta: { type: 'date', overflow: 'auto', textAlign: 'center' },
page: 1,
...calculateMultiLinePosition(1, 0),
customText: 'Apr 16 2026',
},
{
type: FieldType.DATE,
fieldMeta: { type: 'date', textAlign: 'center' },
seedFieldMeta: { type: 'date', overflow: 'auto', textAlign: 'center' },
page: 1,
...calculateMultiLinePosition(1, 1),
customText: 'Wednesday, April 16, 2026 at 14:30:45 Coordinated Universal Time',
},
{
type: FieldType.DATE,
fieldMeta: { type: 'date', textAlign: 'center' },
seedFieldMeta: { type: 'date', overflow: 'auto', textAlign: 'center' },
page: 1,
...calculateMultiLinePosition(1, 2),
customText:
'Wednesday, April 16, 2026 at 14:30:45.123 Coordinated Universal Time signed in Melbourne, Australia. Count to 20, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty',
},
// Multi-line 3×3: Row 2 = TA_RIGHT (short / medium / long)
{
type: FieldType.DATE,
fieldMeta: { type: 'date', textAlign: 'right' },
seedFieldMeta: { type: 'date', overflow: 'auto', textAlign: 'right' },
page: 1,
...calculateMultiLinePosition(2, 0),
customText: 'Apr 16 2026',
},
{
type: FieldType.DATE,
fieldMeta: { type: 'date', textAlign: 'right' },
seedFieldMeta: { type: 'date', overflow: 'auto', textAlign: 'right' },
page: 1,
...calculateMultiLinePosition(2, 1),
customText: 'Wednesday, April 16, 2026 at 14:30:45 Coordinated Universal Time',
},
{
type: FieldType.DATE,
fieldMeta: { type: 'date', textAlign: 'right' },
seedFieldMeta: { type: 'date', overflow: 'auto', textAlign: 'right' },
page: 1,
...calculateMultiLinePosition(2, 2),
customText:
'Wednesday, April 16, 2026 at 14:30:45.123 Coordinated Universal Time signed in Melbourne, Australia. Count to 20, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty',
},
/**
* @@@@@@@@@@@@@@@@@@@@@@@
*
* PAGE 2: EMAIL OVERFLOW
*
* @@@@@@@@@@@@@@@@@@@@@@@
*/
// Single-line: Row 0-2 (default email meta — API auto-adds overflow: 'auto')
{
type: FieldType.EMAIL,
fieldMeta: undefined,
seedFieldMeta: { type: 'email', overflow: 'auto' },
page: 2,
...calculateSingleLinePosition(0),
customText: 'example@documenso.com',
},
{
type: FieldType.EMAIL,
fieldMeta: undefined,
seedFieldMeta: { type: 'email', overflow: 'auto' },
page: 2,
...calculateSingleLinePosition(1),
customText: 'example+medium-overflow-test@documenso.com',
},
{
type: FieldType.EMAIL,
fieldMeta: undefined,
seedFieldMeta: { type: 'email', overflow: 'auto' },
page: 2,
...calculateSingleLinePosition(2),
customText:
'example+maximum-overflow-testing-across-the-page-width-to-verify-text-extends-beyond-field@documenso.com',
},
// Multi-line 3×3: Row 0 = TA_LEFT (short / medium / long)
{
type: FieldType.EMAIL,
fieldMeta: { type: 'email', textAlign: 'left' },
seedFieldMeta: { type: 'email', overflow: 'auto', textAlign: 'left' },
page: 2,
...calculateMultiLinePosition(0, 0),
customText: 'example@documenso.com',
},
{
type: FieldType.EMAIL,
fieldMeta: { type: 'email', textAlign: 'left' },
seedFieldMeta: { type: 'email', overflow: 'auto', textAlign: 'left' },
page: 2,
...calculateMultiLinePosition(0, 1),
customText: 'example+medium-wrapped-text@documenso.com',
},
{
type: FieldType.EMAIL,
fieldMeta: { type: 'email', textAlign: 'left' },
seedFieldMeta: { type: 'email', overflow: 'auto', textAlign: 'left' },
page: 2,
...calculateMultiLinePosition(0, 2),
customText:
'example+this-is-an-extremely-long-email-address-that-is-designed-to-overflow-vertically-out-of-the-field-box-and-extend-well-beyond-the-bottom-of-the-page-to-verify-that-the-vertical-overflow-logic-correctly-handles-text-that-wraps@documenso.com',
},
// Multi-line 3×3: Row 1 = TA_CENTER (short / medium / long)
{
type: FieldType.EMAIL,
fieldMeta: { type: 'email', textAlign: 'center' },
seedFieldMeta: { type: 'email', overflow: 'auto', textAlign: 'center' },
page: 2,
...calculateMultiLinePosition(1, 0),
customText: 'example@documenso.com',
},
{
type: FieldType.EMAIL,
fieldMeta: { type: 'email', textAlign: 'center' },
seedFieldMeta: { type: 'email', overflow: 'auto', textAlign: 'center' },
page: 2,
...calculateMultiLinePosition(1, 1),
customText: 'example+medium-wrapped-text@documenso.com',
},
{
type: FieldType.EMAIL,
fieldMeta: { type: 'email', textAlign: 'center' },
seedFieldMeta: { type: 'email', overflow: 'auto', textAlign: 'center' },
page: 2,
...calculateMultiLinePosition(1, 2),
customText:
'example+this-is-an-extremely-long-email-address-that-is-designed-to-overflow-vertically-out-of-the-field-box-and-extend-well-beyond-the-bottom-of-the-page-to-verify-that-the-vertical-overflow-logic-correctly-handles-text-that-wraps@documenso.com',
},
// Multi-line 3×3: Row 2 = TA_RIGHT (short / medium / long)
{
type: FieldType.EMAIL,
fieldMeta: { type: 'email', textAlign: 'right' },
seedFieldMeta: { type: 'email', overflow: 'auto', textAlign: 'right' },
page: 2,
...calculateMultiLinePosition(2, 0),
customText: 'example@documenso.com',
},
{
type: FieldType.EMAIL,
fieldMeta: { type: 'email', textAlign: 'right' },
seedFieldMeta: { type: 'email', overflow: 'auto', textAlign: 'right' },
page: 2,
...calculateMultiLinePosition(2, 1),
customText: 'example+medium-wrapped-text@documenso.com',
},
{
type: FieldType.EMAIL,
fieldMeta: { type: 'email', textAlign: 'right' },
seedFieldMeta: { type: 'email', overflow: 'auto', textAlign: 'right' },
page: 2,
...calculateMultiLinePosition(2, 2),
customText:
'example+this-is-an-extremely-long-email-address-that-is-designed-to-overflow-vertically-out-of-the-field-box-and-extend-well-beyond-the-bottom-of-the-page-to-verify-that-the-vertical-overflow-logic-correctly-handles-text-that-wraps@documenso.com',
},
/**
* @@@@@@@@@@@@@@@@@@@@@@@
*
* PAGE 3: SIGNATURE OVERFLOW
*
* @@@@@@@@@@@@@@@@@@@@@@@
*/
// Single-line: Row 0-2 (default signature meta — API auto-adds overflow: 'auto')
{
type: FieldType.SIGNATURE,
fieldMeta: undefined,
seedFieldMeta: { type: 'signature', overflow: 'auto' },
page: 3,
...calculateSingleLinePosition(0),
customText: '',
signature: 'John Doe',
},
{
type: FieldType.SIGNATURE,
fieldMeta: undefined,
seedFieldMeta: { type: 'signature', overflow: 'auto' },
page: 3,
...calculateSingleLinePosition(1),
customText: '',
signature: 'My Signature should overflow the field width',
},
{
type: FieldType.SIGNATURE,
fieldMeta: undefined,
seedFieldMeta: { type: 'signature', overflow: 'auto' },
page: 3,
...calculateSingleLinePosition(2),
customText: '',
signature:
'My Signature should overflow the full signature field width and continue across the page to verify text is no longer clipped by the box boundary',
},
// Multi-line stacked: short / medium / long
{
type: FieldType.SIGNATURE,
fieldMeta: undefined,
seedFieldMeta: { type: 'signature', overflow: 'auto' },
page: 3,
...calculateStackedMultiLinePosition(0),
customText: '',
signature: 'John Doe',
},
{
type: FieldType.SIGNATURE,
fieldMeta: undefined,
seedFieldMeta: { type: 'signature', overflow: 'auto' },
page: 3,
...calculateStackedMultiLinePosition(1),
customText: '',
signature: 'My Signature wraps within the tall field',
},
{
type: FieldType.SIGNATURE,
fieldMeta: undefined,
seedFieldMeta: { type: 'signature', overflow: 'auto' },
page: 3,
...calculateStackedMultiLinePosition(2),
customText: '',
signature:
'Count to 40, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty thirty one thirty two thirty three thirty four thirty five thirty six thirty seven thirty eight thirty nine forty',
},
/**
* @@@@@@@@@@@@@@@@@@@@@@@
*
* PAGE 4: TEXT AUTO - SINGLE-LINE (3x3 grid)
*
* @@@@@@@@@@@@@@@@@@@@@@@
*/
// Row 0 (top)
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'top' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'top' },
page: 4,
...calculateTextAutoPosition(0, 0, true),
customText: 'This text should overflow horizontally',
},
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'top' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'top' },
page: 4,
...calculateTextAutoPosition(0, 1, true),
customText: 'This text should overflow horizontally',
},
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'top' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'top' },
page: 4,
...calculateTextAutoPosition(0, 2, true),
customText: 'This text should overflow horizontally',
},
// Row 1 (middle)
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'middle' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'middle' },
page: 4,
...calculateTextAutoPosition(1, 0, true),
customText: 'This text should overflow horizontally',
},
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'middle' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'middle' },
page: 4,
...calculateTextAutoPosition(1, 1, true),
customText: 'This text should overflow horizontally',
},
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'middle' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'middle' },
page: 4,
...calculateTextAutoPosition(1, 2, true),
customText: 'This text should overflow horizontally',
},
// Row 2 (bottom)
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'bottom' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'bottom' },
page: 4,
...calculateTextAutoPosition(2, 0, true),
customText: 'This text should overflow horizontally',
},
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'bottom' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'bottom' },
page: 4,
...calculateTextAutoPosition(2, 1, true),
customText: 'This text should overflow horizontally',
},
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'bottom' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'bottom' },
page: 4,
...calculateTextAutoPosition(2, 2, true),
customText: 'This text should overflow horizontally',
},
/**
* @@@@@@@@@@@@@@@@@@@@@@@
*
* PAGE 5: TEXT AUTO - MULTI-LINE (3x3 grid)
*
* @@@@@@@@@@@@@@@@@@@@@@@
*/
// Row 0 (top)
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'top' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'top' },
page: 5,
...calculateTextAutoPosition(0, 0, false),
customText:
'Count to 20, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty',
},
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'top' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'top' },
page: 5,
...calculateTextAutoPosition(0, 1, false),
customText:
'Count to 20, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty',
},
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'top' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'top' },
page: 5,
...calculateTextAutoPosition(0, 2, false),
customText:
'Count to 20, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty',
},
// Row 1 (middle)
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'middle' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'middle' },
page: 5,
...calculateTextAutoPosition(1, 0, false),
customText:
'Count to 20, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty',
},
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'middle' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'middle' },
page: 5,
...calculateTextAutoPosition(1, 1, false),
customText:
'Count to 20, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty',
},
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'middle' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'middle' },
page: 5,
...calculateTextAutoPosition(1, 2, false),
customText:
'Count to 20, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty',
},
// Row 2 (bottom)
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'bottom' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'bottom' },
page: 5,
...calculateTextAutoPosition(2, 0, false),
customText:
'Count to 20, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty',
},
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'bottom' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'bottom' },
page: 5,
...calculateTextAutoPosition(2, 1, false),
customText:
'Count to 20, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty',
},
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'bottom' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'bottom' },
page: 5,
...calculateTextAutoPosition(2, 2, false),
customText:
'Count to 20, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty',
},
/**
* @@@@@@@@@@@@@@@@@@@@@@@
*
* PAGE 6: TEXT AUTO - MULTI-LINE HEIGHT OVERFLOW
*
* @@@@@@@@@@@@@@@@@@@@@@@
*/
// Same 3×3 grid as page 5 but with longer text that overflows vertically.
// left / top
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'top' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'top' },
page: 6,
...calculateTextAutoPosition(0, 0, false),
customText:
'Count to 40, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty thirty one thirty two thirty three thirty four thirty five thirty six thirty seven thirty eight thirty nine forty',
},
// center / top
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'top' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'top' },
page: 6,
...calculateTextAutoPosition(0, 1, false),
customText:
'Count to 40, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty thirty one thirty two thirty three thirty four thirty five thirty six thirty seven thirty eight thirty nine forty',
},
// right / top
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'top' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'top' },
page: 6,
...calculateTextAutoPosition(0, 2, false),
customText:
'Count to 40, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty thirty one thirty two thirty three thirty four thirty five thirty six thirty seven thirty eight thirty nine forty',
},
// left / middle
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'middle' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'middle' },
page: 6,
...calculateTextAutoPosition(1, 0, false),
customText:
'Count to 40, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty thirty one thirty two thirty three thirty four thirty five thirty six thirty seven thirty eight thirty nine forty',
},
// center / middle
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'middle' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'middle' },
page: 6,
...calculateTextAutoPosition(1, 1, false),
customText:
'Count to 40, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty thirty one thirty two thirty three thirty four thirty five thirty six thirty seven thirty eight thirty nine forty',
},
// right / middle
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'middle' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'middle' },
page: 6,
...calculateTextAutoPosition(1, 2, false),
customText:
'Count to 40, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty thirty one thirty two thirty three thirty four thirty five thirty six thirty seven thirty eight thirty nine forty',
},
// left / bottom
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'bottom' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'left', verticalAlign: 'bottom' },
page: 6,
...calculateTextAutoPosition(2, 0, false),
customText:
'Count to 40, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty thirty one thirty two thirty three thirty four thirty five thirty six thirty seven thirty eight thirty nine forty',
},
// center / bottom
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'bottom' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'center', verticalAlign: 'bottom' },
page: 6,
...calculateTextAutoPosition(2, 1, false),
customText:
'Count to 40, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty thirty one thirty two thirty three thirty four thirty five thirty six thirty seven thirty eight thirty nine forty',
},
// right / bottom
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'bottom' },
seedFieldMeta: { type: 'text', overflow: 'auto', textAlign: 'right', verticalAlign: 'bottom' },
page: 6,
...calculateTextAutoPosition(2, 2, false),
customText:
'Count to 40, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty thirty one thirty two thirty three thirty four thirty five thirty six thirty seven thirty eight thirty nine forty',
},
/**
* @@@@@@@@@@@@@@@@@@@@@@@
*
* PAGE 7: EXPLICIT MODES
*
* @@@@@@@@@@@@@@@@@@@@@@@
*/
// Section A: Horizontal mode (3 boxes in a row)
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'horizontal', textAlign: 'left' },
seedFieldMeta: { type: 'text', overflow: 'horizontal', textAlign: 'left' },
page: 7,
...calculateExplicitHorizontalPosition(0),
customText: 'Explicit horizontal overflow text that should extend beyond the field',
},
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'horizontal', textAlign: 'center' },
seedFieldMeta: { type: 'text', overflow: 'horizontal', textAlign: 'center' },
page: 7,
...calculateExplicitHorizontalPosition(1),
customText: 'Explicit horizontal overflow text that should extend beyond the field',
},
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'horizontal', textAlign: 'right' },
seedFieldMeta: { type: 'text', overflow: 'horizontal', textAlign: 'right' },
page: 7,
...calculateExplicitHorizontalPosition(2),
customText: 'Explicit horizontal overflow text that should extend beyond the field',
},
// Section B: Vertical mode (3 boxes in a column)
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'vertical', verticalAlign: 'top' },
seedFieldMeta: { type: 'text', overflow: 'vertical', verticalAlign: 'top' },
page: 7,
...calculateExplicitVerticalPosition(0),
customText:
'Count to 30, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty',
},
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'vertical', verticalAlign: 'middle' },
seedFieldMeta: { type: 'text', overflow: 'vertical', verticalAlign: 'middle' },
page: 7,
...calculateExplicitVerticalPosition(1),
customText:
'Count to 30, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty',
},
{
type: FieldType.TEXT,
fieldMeta: { type: 'text', overflow: 'vertical', verticalAlign: 'bottom' },
seedFieldMeta: { type: 'text', overflow: 'vertical', verticalAlign: 'bottom' },
page: 7,
...calculateExplicitVerticalPosition(2),
customText:
'Count to 30, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty',
},
/**
* @@@@@@@@@@@@@@@@@@@@@@@
*
* PAGE 8: CROP MODE
*
* @@@@@@@@@@@@@@@@@@@@@@@
*/
// Box 1: Single-line crop
{
type: FieldType.TEXT,
fieldMeta: undefined,
seedFieldMeta: { type: 'text' },
page: 8,
positionX: 10,
positionY: 15,
width: 25,
height: SINGLE_LINE_HEIGHT,
customText: 'This text should be cropped and not overflow',
},
// Box 2: Multi-line crop
{
type: FieldType.TEXT,
fieldMeta: undefined,
seedFieldMeta: { type: 'text' },
page: 8,
positionX: 10,
positionY: 30,
width: 25,
height: MULTI_LINE_HEIGHT,
customText:
'Count to 30, one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty one twenty two twenty three twenty four twenty five twenty six twenty seven twenty eight twenty nine thirty',
},
] as const;
@@ -31,6 +31,9 @@ const baseUrl = `${WEBAPP_BASE_URL}/api/v2`;
test.describe.configure({ mode: 'parallel', timeout: 60000 });
/**
* DON'T COMMIT THIS WITHOUT THE "SKIP" COMMAND.
*/
test.skip('seed alignment test document', async ({ page }) => {
const user = await prisma.user.findFirstOrThrow({
where: {
@@ -232,6 +235,47 @@ test('field placement visual regression', async ({ page, request }, testInfo) =>
}),
);
// Override email fields with test values after distribution.
// Email fields are auto-inserted with the signer's email during distribution,
// so we override customText directly to test with specific values.
const emailFields = await prisma.field.findMany({
where: {
envelopeId: envelope.id,
type: FieldType.EMAIL,
},
include: {
envelopeItem: {
select: {
title: true,
},
},
},
});
await Promise.all(
emailFields.map(async (field) => {
const testFields =
field.envelopeItem.title === 'alignment-pdf'
? ALIGNMENT_TEST_FIELDS
: FIELD_META_TEST_FIELDS;
const foundField = testFields.find(
(f) =>
f.type === FieldType.EMAIL &&
field.page === f.page &&
Number(field.positionX).toFixed(2) === f.positionX.toFixed(2) &&
Number(field.positionY).toFixed(2) === f.positionY.toFixed(2),
);
if (foundField) {
await prisma.field.update({
where: { id: field.id },
data: { customText: foundField.customText },
});
}
}),
);
const recipientToken = envelope.recipients[0].token;
const signUrl = `/sign/${recipientToken}`;
@@ -335,6 +379,45 @@ test.skip('download envelope images', async ({ page, request }) => {
expect(distributeEnvelopeRequest.ok()).toBeTruthy();
// Override email fields with test values after distribution.
const emailFields = await prisma.field.findMany({
where: {
envelopeId: envelope.id,
type: FieldType.EMAIL,
},
include: {
envelopeItem: {
select: {
title: true,
},
},
},
});
await Promise.all(
emailFields.map(async (field) => {
const testFields =
field.envelopeItem.title === 'alignment-pdf'
? ALIGNMENT_TEST_FIELDS
: FIELD_META_TEST_FIELDS;
const foundField = testFields.find(
(f) =>
f.type === FieldType.EMAIL &&
field.page === f.page &&
Number(field.positionX).toFixed(2) === f.positionX.toFixed(2) &&
Number(field.positionY).toFixed(2) === f.positionY.toFixed(2),
);
if (foundField) {
await prisma.field.update({
where: { id: field.id },
data: { customText: foundField.customText },
});
}
}),
);
const token = envelope.recipients[0].token;
const signUrl = `/sign/${token}`;
@@ -0,0 +1,543 @@
import { createCanvas } from '@napi-rs/canvas';
import type { TestInfo } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { DocumentStatus, EnvelopeType, FieldType } from '@prisma/client';
import fs from 'node:fs';
import path from 'node:path';
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs';
import pixelMatch from 'pixelmatch';
import { PNG } from 'pngjs';
import { getEnvelopeItemPdfUrl } from '@documenso/lib/utils/envelope-download';
import { prisma } from '@documenso/prisma';
import { seedOverflowTestDocument } from '@documenso/prisma/seed/initial-seed';
import { seedUser } from '@documenso/prisma/seed/users';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../lib/constants/app';
import { isBase64Image } from '../../../lib/constants/signatures';
import { createApiToken } from '../../../lib/server-only/public-api/create-api-token';
import { RecipientRole } from '../../../prisma/generated/types';
import type {
TCreateEnvelopePayload,
TCreateEnvelopeResponse,
} from '../../../trpc/server/envelope-router/create-envelope.types';
import type { TDistributeEnvelopeRequest } from '../../../trpc/server/envelope-router/distribute-envelope.types';
import { OVERFLOW_TEST_FIELDS } from '../../constants/field-overflow-pdf';
import { apiSignin } from '../fixtures/authentication';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2`;
test.describe.configure({ mode: 'parallel', timeout: 60000 });
/**
* DON'T COMMIT THIS WITHOUT THE "SKIP" COMMAND.
*/
test.skip('seed overflow test document', async ({ page }) => {
const user = await prisma.user.findFirstOrThrow({
where: {
email: 'example@documenso.com',
},
include: {
ownedOrganisations: {
include: {
teams: true,
},
},
},
});
const userId = user.id;
const teamId = user.ownedOrganisations[0].teams[0].id;
await seedOverflowTestDocument({
userId,
teamId,
recipientName: user.name || '',
recipientEmail: user.email,
insertFields: false,
status: DocumentStatus.DRAFT,
});
});
test('overflow visual regression', async ({ page, request }, testInfo) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
// Step 1: Create initial envelope with overflow PDF
const overflowPdf = fs.readFileSync(
path.join(__dirname, '../../../../assets/field-overflow.pdf'),
);
const formData = new FormData();
const overflowFields = OVERFLOW_TEST_FIELDS.map((field) => ({
identifier: 'field-overflow',
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
fieldMeta: field.fieldMeta,
}));
const createEnvelopePayload: TCreateEnvelopePayload = {
type: EnvelopeType.DOCUMENT,
title: 'Overflow Test',
recipients: [
{
email: user.email,
name: user.name || '',
role: RecipientRole.SIGNER,
fields: overflowFields,
},
],
};
formData.append('payload', JSON.stringify(createEnvelopePayload));
formData.append('files', new File([overflowPdf], 'field-overflow', { type: 'application/pdf' }));
const createEnvelopeRequest = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(createEnvelopeRequest.ok()).toBeTruthy();
expect(createEnvelopeRequest.status()).toBe(200);
const { id: createdEnvelopeId }: TCreateEnvelopeResponse = await createEnvelopeRequest.json();
const envelope = await prisma.envelope.findUniqueOrThrow({
where: {
id: createdEnvelopeId,
},
include: {
recipients: true,
envelopeItems: true,
},
});
const recipientId = envelope.recipients[0].id;
const overflowItem = envelope.envelopeItems.find((item: { order: number }) => item.order === 1);
expect(recipientId).toBeDefined();
expect(overflowItem).toBeDefined();
if (!overflowItem) {
throw new Error('Envelope item not found');
}
const distributeEnvelopeRequest = await request.post(`${baseUrl}/envelope/distribute`, {
headers: { Authorization: `Bearer ${token}` },
data: {
envelopeId: envelope.id,
} satisfies TDistributeEnvelopeRequest,
});
expect(distributeEnvelopeRequest.ok()).toBeTruthy();
const uninsertedFields = await prisma.field.findMany({
where: {
envelopeId: envelope.id,
OR: [
{
inserted: false,
},
{
// Include email fields because they are automatically inserted during envelope distribution.
// We need to extract it to override their values for accurate comparison in tests.
type: FieldType.EMAIL,
},
],
},
include: {
envelopeItem: {
select: {
title: true,
},
},
},
});
await Promise.all(
uninsertedFields.map(async (field) => {
const foundField = OVERFLOW_TEST_FIELDS.find(
(f) =>
field.page === f.page &&
Number(field.positionX).toFixed(2) === f.positionX.toFixed(2) &&
Number(field.positionY).toFixed(2) === f.positionY.toFixed(2) &&
Number(field.width).toFixed(2) === f.width.toFixed(2) &&
Number(field.height).toFixed(2) === f.height.toFixed(2),
);
if (!foundField) {
throw new Error('Field not found');
}
await prisma.field.update({
where: {
id: field.id,
},
data: {
inserted: true,
customText: foundField.customText,
signature: foundField.signature
? {
create: {
recipientId: envelope.recipients[0].id,
signatureImageAsBase64: isBase64Image(foundField.signature)
? foundField.signature
: null,
typedSignature: isBase64Image(foundField.signature) ? null : foundField.signature,
},
}
: undefined,
},
});
}),
);
// Override email fields with test values after distribution.
// Email fields are auto-inserted with the signer's email during distribution,
// so we override customText directly to test overflow with specific text lengths.
const emailFields = await prisma.field.findMany({
where: {
envelopeId: envelope.id,
type: FieldType.EMAIL,
},
});
await Promise.all(
emailFields.map(async (field) => {
const foundField = OVERFLOW_TEST_FIELDS.find(
(f) =>
f.type === FieldType.EMAIL &&
field.page === f.page &&
Number(field.positionX).toFixed(2) === f.positionX.toFixed(2) &&
Number(field.positionY).toFixed(2) === f.positionY.toFixed(2),
);
if (foundField) {
await prisma.field.update({
where: { id: field.id },
data: { customText: foundField.customText },
});
}
}),
);
const recipientToken = envelope.recipients[0].token;
const signUrl = `/sign/${recipientToken}`;
await apiSignin({
page,
email: user.email,
redirectPath: signUrl,
});
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`${signUrl}/complete`);
await expect(async () => {
const { status } = await prisma.envelope.findFirstOrThrow({
where: {
id: envelope.id,
},
});
expect(status).toBe(DocumentStatus.COMPLETED);
}).toPass({
timeout: 10000,
});
const completedDocument = await prisma.envelope.findFirstOrThrow({
where: {
id: envelope.id,
},
include: {
envelopeItems: {
orderBy: {
order: 'asc',
},
include: {
documentData: true,
},
},
},
});
const storedImages = fs.readdirSync(path.join(__dirname, '../../visual-regression'));
await Promise.all(
completedDocument.envelopeItems.map(async (item) => {
const documentUrl = getEnvelopeItemPdfUrl({
type: 'download',
envelopeItem: item,
token: recipientToken,
version: 'signed',
});
const pdfData = await fetch(documentUrl).then(async (res) => await res.arrayBuffer());
const loadedImages = storedImages
.filter((image) => image.startsWith(`field-overflow-`))
.sort((leftImage, rightImage) => {
return (
getVisualRegressionImageIndex(leftImage) - getVisualRegressionImageIndex(rightImage)
);
})
.map((image) => fs.readFileSync(path.join(__dirname, '../../visual-regression', image)));
await compareSignedPdfWithImages({
id: 'field-overflow',
pdfData: new Uint8Array(pdfData),
images: loadedImages,
testInfo,
});
}),
);
});
/**
* Used to download the envelope images when updating the visual regression test.
*
* DON'T COMMIT THIS WITHOUT THE "SKIP" COMMAND.
*/
test.skip('download overflow images', async ({ page, request }) => {
const { user, team } = await seedUser();
const { token: apiToken } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
const envelope = await seedOverflowTestDocument({
userId: user.id,
teamId: team.id,
recipientName: user.name || '',
recipientEmail: user.email,
insertFields: true,
status: DocumentStatus.DRAFT,
});
const distributeEnvelopeRequest = await request.post(`${baseUrl}/envelope/distribute`, {
headers: { Authorization: `Bearer ${apiToken}` },
data: {
envelopeId: envelope.id,
} satisfies TDistributeEnvelopeRequest,
});
expect(distributeEnvelopeRequest.ok()).toBeTruthy();
// Override email fields with test values after distribution.
const emailFields = await prisma.field.findMany({
where: {
envelopeId: envelope.id,
type: FieldType.EMAIL,
},
});
await Promise.all(
emailFields.map(async (field) => {
const foundField = OVERFLOW_TEST_FIELDS.find(
(f) =>
f.type === FieldType.EMAIL &&
field.page === f.page &&
Number(field.positionX).toFixed(2) === f.positionX.toFixed(2) &&
Number(field.positionY).toFixed(2) === f.positionY.toFixed(2),
);
if (foundField) {
await prisma.field.update({
where: { id: field.id },
data: { customText: foundField.customText },
});
}
}),
);
const token = envelope.recipients[0].token;
const signUrl = `/sign/${token}`;
await apiSignin({
page,
email: user.email,
redirectPath: signUrl,
});
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`${signUrl}/complete`);
await expect(async () => {
const { status } = await prisma.envelope.findFirstOrThrow({
where: {
id: envelope.id,
},
});
expect(status).toBe(DocumentStatus.COMPLETED);
}).toPass({
timeout: 10000,
});
const completedDocument = await prisma.envelope.findFirstOrThrow({
where: {
id: envelope.id,
},
include: {
envelopeItems: {
orderBy: {
order: 'asc',
},
include: {
documentData: true,
},
},
},
});
await Promise.all(
completedDocument.envelopeItems.map(async (item) => {
const documentUrl = getEnvelopeItemPdfUrl({
type: 'download',
envelopeItem: item,
token,
version: 'signed',
});
const pdfData = await fetch(documentUrl).then(async (res) => await res.arrayBuffer());
const pdfImages = await renderPdfToImage(new Uint8Array(pdfData));
for (const [index, { image }] of pdfImages.entries()) {
fs.writeFileSync(
path.join(__dirname, '../../visual-regression', `field-overflow-${index}.png`),
new Uint8Array(image),
);
}
}),
);
});
// ============================================================================
// Helper functions
// ============================================================================
async function renderPdfToImage(pdfBytes: Uint8Array) {
const loadingTask = pdfjsLib.getDocument({ data: pdfBytes });
const pdf = await loadingTask.promise;
// Increase for higher resolution
const scale = 4;
return await Promise.all(
Array.from({ length: pdf.numPages }, async (_, index) => {
const page = await pdf.getPage(index + 1);
const viewport = page.getViewport({ scale });
const canvas = createCanvas(viewport.width, viewport.height);
const canvasContext = canvas.getContext('2d');
canvasContext.imageSmoothingEnabled = false;
await page.render({
// @ts-expect-error @napi-rs/canvas satisfies runtime requirements for pdfjs
canvas,
// @ts-expect-error @napi-rs/canvas satisfies runtime requirements for pdfjs
canvasContext,
viewport,
}).promise;
return {
image: await canvas.encode('png'),
// Rounded down because the certificate page somehow gives dimensions with decimals
width: Math.floor(viewport.width),
height: Math.floor(viewport.height),
};
}),
);
}
type CompareSignedPdfWithImagesOptions = {
id: string;
pdfData: Uint8Array;
images: Buffer[];
testInfo: TestInfo;
};
const compareSignedPdfWithImages = async ({
id,
pdfData,
images,
testInfo,
}: CompareSignedPdfWithImagesOptions) => {
const renderedImages = await renderPdfToImage(pdfData);
expect(images).toHaveLength(renderedImages.length);
for (const [index, { image, width, height }] of renderedImages.entries()) {
const isCertificate = index === renderedImages.length - 1;
// Skip certificate page comparison.
if (isCertificate) {
continue;
}
const diff = new PNG({ width, height });
const storedImage = PNG.sync.read(images[index]).data;
const newImage = PNG.sync.read(image).data;
const comparison = pixelMatch(
new Uint8Array(storedImage),
new Uint8Array(newImage),
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
diff.data as unknown as Uint8Array,
width,
height,
{
threshold: 0.25,
// includeAA: true, // This allows stricter testing.
},
);
console.log(`${id}-${index}: ${comparison}`);
const diffFilePath = path.join(testInfo.outputPath(), `${id}-${index}-diff.png`);
const oldFilePath = path.join(testInfo.outputPath(), `${id}-${index}-old.png`);
const newFilePath = path.join(testInfo.outputPath(), `${id}-${index}-new.png`);
fs.writeFileSync(diffFilePath, new Uint8Array(PNG.sync.write(diff)));
fs.writeFileSync(oldFilePath, new Uint8Array(images[index]));
fs.writeFileSync(newFilePath, new Uint8Array(image));
expect.soft(comparison).toBeLessThan(2);
}
};
const getVisualRegressionImageIndex = (image: string) => {
const match = image.match(/-(\d+)\.png$/);
if (!match) {
throw new Error(`Unexpected visual regression image name: ${image}`);
}
return Number(match[1]);
};
Binary file not shown.

Before

Width:  |  Height:  |  Size: 352 KiB

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 622 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 636 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 575 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 506 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

+35
View File
@@ -16,6 +16,34 @@ export const FIELD_MAX_LETTER_SPACING = 100;
export const DEFAULT_FIELD_FONT_SIZE = 12;
export const DEFAULT_SIGNATURE_OVERFLOW_MODE = 'auto';
export const DEFAULT_DATE_OVERFLOW_MODE = 'auto';
export const DEFAULT_EMAIL_OVERFLOW_MODE = 'auto';
/**
* The overflow mode for a field.
*
* - 'auto': Will overflow horizontally if no room to wrap vertically.
* - 'horizontal': Overflow horizontally, will not wrap at all.
* - 'vertical': Overflow vertically, will wrap at the field width.
* - 'crop': Crop the text to the field bounds, will not overflow at all.
*
* @default 'crop'
*/
export const ZFieldOverflowMode = z.enum(['auto', 'horizontal', 'vertical', 'crop']);
export type TFieldOverflowMode = z.infer<typeof ZFieldOverflowMode>;
/**
* Resolves the overflow mode for a field.
*
* Returns 'crop' when undefined (the default for most fields).
*/
export const resolveFieldOverflowMode = (
fieldMeta?: { overflow?: TFieldOverflowMode } | null,
): TFieldOverflowMode => {
return fieldMeta?.overflow ?? 'crop';
};
/**
* Grouped field types that use the same generic text rendering function.
*/
@@ -47,6 +75,7 @@ export const ZBaseFieldMeta = z.object({
required: z.boolean().optional(),
readOnly: z.boolean().optional(),
fontSize: z.number().min(8).max(96).default(DEFAULT_FIELD_FONT_SIZE).optional(),
overflow: ZFieldOverflowMode.optional(),
});
export type TBaseFieldMeta = z.infer<typeof ZBaseFieldMeta>;
@@ -72,6 +101,7 @@ export type TNameFieldMeta = z.infer<typeof ZNameFieldMeta>;
export const ZEmailFieldMeta = ZBaseFieldMeta.extend({
type: z.literal('email'),
textAlign: ZFieldTextAlignSchema.optional(),
overflow: ZFieldOverflowMode.optional().default(DEFAULT_EMAIL_OVERFLOW_MODE),
});
export type TEmailFieldMeta = z.infer<typeof ZEmailFieldMeta>;
@@ -79,6 +109,7 @@ export type TEmailFieldMeta = z.infer<typeof ZEmailFieldMeta>;
export const ZDateFieldMeta = ZBaseFieldMeta.extend({
type: z.literal('date'),
textAlign: ZFieldTextAlignSchema.optional(),
overflow: ZFieldOverflowMode.optional().default(DEFAULT_DATE_OVERFLOW_MODE),
});
export type TDateFieldMeta = z.infer<typeof ZDateFieldMeta>;
@@ -156,6 +187,7 @@ export type TDropdownFieldMeta = z.infer<typeof ZDropdownFieldMeta>;
export const ZSignatureFieldMeta = ZBaseFieldMeta.extend({
type: z.literal('signature'),
overflow: ZFieldOverflowMode.optional().default(DEFAULT_SIGNATURE_OVERFLOW_MODE),
});
export type TSignatureFieldMeta = z.infer<typeof ZSignatureFieldMeta>;
@@ -283,6 +315,7 @@ export const FIELD_DATE_META_DEFAULT_VALUES: TDateFieldMeta = {
type: 'date',
fontSize: DEFAULT_FIELD_FONT_SIZE,
textAlign: 'left',
overflow: DEFAULT_DATE_OVERFLOW_MODE,
};
export const FIELD_TEXT_META_DEFAULT_VALUES: TTextFieldMeta = {
@@ -322,6 +355,7 @@ export const FIELD_EMAIL_META_DEFAULT_VALUES: TEmailFieldMeta = {
type: 'email',
fontSize: DEFAULT_FIELD_FONT_SIZE,
textAlign: 'left',
overflow: DEFAULT_EMAIL_OVERFLOW_MODE,
};
export const FIELD_RADIO_META_DEFAULT_VALUES: TRadioFieldMeta = {
@@ -356,6 +390,7 @@ export const FIELD_DROPDOWN_META_DEFAULT_VALUES: TDropdownFieldMeta = {
export const FIELD_SIGNATURE_META_DEFAULT_VALUES: TSignatureFieldMeta = {
type: 'signature',
fontSize: DEFAULT_SIGNATURE_TEXT_FONT_SIZE,
overflow: DEFAULT_SIGNATURE_OVERFLOW_MODE,
};
export const FIELD_META_DEFAULT_VALUES: Record<FieldType, TFieldMetaSchema> = {
@@ -0,0 +1,340 @@
import Konva from 'konva';
import type { TFieldOverflowMode } from '../../types/field-meta';
type OverflowLayoutParams = {
/** The resolved overflow mode ('crop' | 'auto' | 'horizontal' | 'vertical'). */
overflowMode: TFieldOverflowMode;
/** True when rendering the field type name (like "Text", "Date", "Email") or a user label, not actual user content. */
isLabel: boolean;
/** The text content to render. Used to determine if text overflows the field bounds. */
textToRender: string;
/** Font size in pixels. */
fontSize: number;
/** CSS font family string. */
fontFamily: string;
/** Line height multiplier. */
lineHeight: number;
/** Letter spacing in pixels. */
letterSpacing: number;
/** Horizontal text alignment. */
textAlign: 'left' | 'center' | 'right';
/** Vertical text alignment. */
verticalAlign: 'top' | 'middle' | 'bottom';
/** Text x position within the group (e.g. padding offset). */
baseX: number;
/** Text y position within the group. */
baseY: number;
/** Text width at field bounds (fieldWidth minus any padding). */
baseWidth: number;
/** Text height at field bounds (fieldHeight). */
baseHeight: number;
/** Group x position on the page (fieldX from calculateFieldPosition). */
groupX: number;
/** Group y position on the page (fieldY from calculateFieldPosition). */
groupY: number;
/** Full page width in pixels. */
pageWidth: number;
/** Full page height in pixels. */
pageHeight: number;
};
type OverflowLayoutResult = {
x: number;
y: number;
width: number;
height: number;
wrap: 'word' | 'none';
textAlign: 'left' | 'center' | 'right';
verticalAlign: 'top' | 'middle' | 'bottom';
};
/**
* Calculate layout metrics for the text within the field.
*
* Returns:
* - exceedsWidth: whether the unwrapped text exceeds the field width
* - exceedsHeightWhenWrapped: whether wrapping the text at field width exceeds the field height
* - hasRoomForMoreThanOneLine: whether the field can fit 2+ lines of text
*/
const calculateLayout = (params: {
textToRender: string;
fontSize: number;
fontFamily: string;
lineHeight: number;
letterSpacing: number;
baseWidth: number;
baseHeight: number;
}): {
exceedsWidth: boolean;
exceedsHeightWhenWrapped: boolean;
hasRoomForMoreThanOneLine: boolean;
} => {
const { textToRender, fontSize, fontFamily, lineHeight, letterSpacing, baseWidth, baseHeight } =
params;
// Measure the text without width constraint to get natural width and single-line height.
const unwrappedNode = new Konva.Text({
text: textToRender,
fontSize,
fontFamily,
lineHeight,
letterSpacing,
});
const exceedsWidth = unwrappedNode.width() > baseWidth;
const oneLineHeight = unwrappedNode.height();
unwrappedNode.destroy();
const hasRoomForMoreThanOneLine = baseHeight >= oneLineHeight * 2;
// Measure the text wrapped at field width to check vertical overflow.
const wrappedNode = new Konva.Text({
text: textToRender,
fontSize,
fontFamily,
lineHeight,
letterSpacing,
width: baseWidth,
wrap: 'word',
});
const exceedsHeightWhenWrapped = wrappedNode.height() > baseHeight;
wrappedNode.destroy();
return { exceedsWidth, exceedsHeightWhenWrapped, hasRoomForMoreThanOneLine };
};
/**
* Calculate horizontal overflow layout based on text alignment.
*
* The text node is expanded beyond the field bounds toward the page edges.
* - left-aligned: extends rightward to page right edge
* - right-aligned: extends leftward to page left edge
* - center-aligned: extends symmetrically toward the closer page edge
*/
const calculateHorizontalOverflow = (params: OverflowLayoutParams): OverflowLayoutResult => {
const { textAlign, baseX, baseY, baseWidth, baseHeight, groupX, pageWidth } = params;
if (textAlign === 'right') {
// Extend leftward to page left edge.
// Right edge of text stays at (baseX + baseWidth) within the group.
const newX = -groupX;
const newWidth = groupX + baseX + baseWidth;
return {
x: newX,
y: baseY,
width: newWidth,
height: baseHeight,
wrap: 'none',
textAlign,
verticalAlign: params.verticalAlign,
};
}
if (textAlign === 'center') {
// Extend symmetrically from the text center toward the closer page edge.
const leftSpace = groupX + baseX;
const rightSpace = pageWidth - (groupX + baseX + baseWidth);
const maxExtend = Math.min(leftSpace, rightSpace);
const newX = baseX - maxExtend;
const newWidth = baseWidth + maxExtend * 2;
return {
x: newX,
y: baseY,
width: newWidth,
height: baseHeight,
wrap: 'none',
textAlign,
verticalAlign: params.verticalAlign,
};
}
// Default: left-aligned — extend rightward to page right edge.
const newWidth = pageWidth - groupX - baseX;
return {
x: baseX,
y: baseY,
width: newWidth,
height: baseHeight,
wrap: 'none',
textAlign,
verticalAlign: params.verticalAlign,
};
};
/**
* Calculate vertical overflow layout based on vertical alignment.
*
* The text node keeps the field width (text wraps) and expands height toward the page edges.
* - top aligned: extends downward to page bottom
* - bottom aligned: extends upward to page top
* - middle aligned: extends symmetrically up and down toward the closer page edge
*/
const calculateVerticalOverflow = (params: OverflowLayoutParams): OverflowLayoutResult => {
const { verticalAlign, textAlign, baseX, baseY, baseWidth, baseHeight, groupY, pageHeight } =
params;
if (verticalAlign === 'bottom') {
// Extend upward to page top edge.
// Bottom edge of text stays at (baseY + baseHeight) within the group.
const newY = -groupY;
const newHeight = groupY + baseY + baseHeight;
return {
x: baseX,
y: newY,
width: baseWidth,
height: newHeight,
wrap: 'word',
textAlign,
verticalAlign: 'bottom',
};
}
if (verticalAlign === 'middle') {
// Extend both up and down from the field center.
// Text stays vertically centered at the original field position.
const upSpace = groupY + baseY;
const downSpace = pageHeight - (groupY + baseY + baseHeight);
const maxExtend = Math.min(upSpace, downSpace);
const newY = baseY - maxExtend;
const newHeight = baseHeight + maxExtend * 2;
return {
x: baseX,
y: newY,
width: baseWidth,
height: newHeight,
wrap: 'word',
textAlign,
verticalAlign: 'middle',
};
}
// Default: top — extend downward to page bottom edge.
const newHeight = pageHeight - groupY - baseY;
return {
x: baseX,
y: baseY,
width: baseWidth,
height: newHeight,
wrap: 'word',
textAlign,
verticalAlign: 'top',
};
};
/**
* Calculate overflow-aware text layout dimensions.
*
* Returns { x, y, width, height, wrap } to spread into a Konva.Text setAttrs() call.
*
* For 'crop' mode or placeholder content, returns the original field bounds (current behavior).
* For 'horizontal'/'vertical'/'auto', expands the text node dimensions toward the page edges
* based on text alignment and field position.
*/
export const calculateOverflowLayout = (params: OverflowLayoutParams): OverflowLayoutResult => {
const { overflowMode, isLabel, baseX, baseY, baseWidth, baseHeight } = params;
// No overflow for placeholders or crop mode — return original field bounds.
if (isLabel || overflowMode === 'crop') {
return {
x: baseX,
y: baseY,
width: baseWidth,
height: baseHeight,
wrap: 'word',
textAlign: params.textAlign,
verticalAlign: params.verticalAlign,
};
}
if (overflowMode === 'horizontal') {
return calculateHorizontalOverflow(params);
}
if (overflowMode === 'vertical') {
return calculateVerticalOverflow(params);
}
// Auto mode: measure the text and field to decide overflow direction.
const layout = calculateLayout({
textToRender: params.textToRender,
fontSize: params.fontSize,
fontFamily: params.fontFamily,
lineHeight: params.lineHeight,
letterSpacing: params.letterSpacing,
baseWidth,
baseHeight,
});
// Auto single-line: overflow horizontal only when text exceeds field width.
// Center text align is overridden to left so it overflows right.
if (!layout.hasRoomForMoreThanOneLine) {
if (!layout.exceedsWidth) {
// Text fits — keep original alignment, no overflow needed.
return {
x: baseX,
y: baseY,
width: baseWidth,
height: baseHeight,
wrap: 'none',
textAlign: params.textAlign,
verticalAlign: params.verticalAlign,
};
}
return calculateHorizontalOverflow({
...params,
textAlign: params.textAlign === 'center' ? 'left' : params.textAlign,
});
}
// Auto multi-line: overflow vertical only when wrapped text exceeds field height.
// Middle vertical align is only overridden to top if the text actually overflows vertically.
// If it fits, keep middle so the text stays centered within the field.
if (!layout.exceedsHeightWhenWrapped) {
// Text fits when wrapped — keep original alignment, no overflow needed.
return {
x: baseX,
y: baseY,
width: baseWidth,
height: baseHeight,
wrap: 'word',
textAlign: params.textAlign,
verticalAlign: params.verticalAlign,
};
}
const verticalAlignOverride = params.verticalAlign === 'middle' ? 'top' : params.verticalAlign;
return calculateVerticalOverflow({
...params,
verticalAlign: verticalAlignOverride,
});
};
@@ -7,7 +7,9 @@ import {
FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN,
FIELD_DEFAULT_LETTER_SPACING,
FIELD_DEFAULT_LINE_HEIGHT,
resolveFieldOverflowMode,
} from '../../types/field-meta';
import { calculateOverflowLayout } from './calculate-overflow-layout';
import {
createFieldHoverInteraction,
konvaTextFill,
@@ -20,10 +22,14 @@ import { calculateFieldPosition } from './field-renderer';
const DEFAULT_TEXT_X_PADDING = 6;
const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOptions): Konva.Text => {
const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOptions) => {
const { pageWidth, pageHeight, mode = 'edit', pageLayer, translations } = options;
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
const { fieldX, fieldY, fieldWidth, fieldHeight } = calculateFieldPosition(
field,
pageWidth,
pageHeight,
);
const fieldMeta = field.fieldMeta as GenericTextFieldTypeMetas | undefined;
@@ -41,7 +47,10 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption
const textY = 0;
const textFontSize = fieldMeta?.fontSize || DEFAULT_STANDARD_FONT_SIZE;
// By default, render the field name or label centered
// By default, render the field name or label centered.
// isLabel tracks whether we're rendering the field type name (like "Text", "Date", "Email")
// or a user label — overflow should not apply to these, only to actual content.
let isLabel = true;
let textToRender: string = fieldMeta?.label || fieldTypeName;
let textAlign: 'left' | 'center' | 'right' = 'center';
let textVerticalAlign: 'top' | 'middle' | 'bottom' = FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN;
@@ -53,6 +62,7 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption
const value = fieldMeta?.type === 'text' ? fieldMeta.text : fieldMeta.value;
if (value) {
isLabel = false;
textToRender = value;
textVerticalAlign = fieldMeta.verticalAlign || FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN;
@@ -73,6 +83,7 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption
const value = fieldMeta?.type === 'text' ? fieldMeta.text : fieldMeta.value;
if (value) {
isLabel = false;
textToRender = value;
textVerticalAlign = fieldMeta.verticalAlign || FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN;
@@ -84,6 +95,7 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption
// Override everything with value if it's inserted.
if (field.inserted) {
isLabel = false;
textToRender = field.customText;
textAlign = fieldMeta?.textAlign || FIELD_DEFAULT_GENERIC_ALIGN;
@@ -95,25 +107,54 @@ const upsertFieldText = (field: FieldToRender, options: RenderFieldElementOption
}
}
const overflowLayout = calculateOverflowLayout({
overflowMode: resolveFieldOverflowMode(fieldMeta),
isLabel,
textToRender,
fontSize: textFontSize,
fontFamily: konvaTextFontFamily,
lineHeight: textLineHeight,
letterSpacing: textLetterSpacing,
textAlign,
verticalAlign: textVerticalAlign,
baseX: textX + DEFAULT_TEXT_X_PADDING,
baseY: textY,
baseWidth: fieldWidth - DEFAULT_TEXT_X_PADDING * 2,
baseHeight: fieldHeight,
groupX: fieldX,
groupY: fieldY,
pageWidth,
pageHeight,
});
// Note: Do not use native text padding since it's uniform.
// We only want to have padding on the left and right hand sides.
fieldText.setAttrs({
x: textX + DEFAULT_TEXT_X_PADDING,
y: textY,
verticalAlign: textVerticalAlign,
wrap: 'word',
x: overflowLayout.x,
y: overflowLayout.y,
verticalAlign: overflowLayout.verticalAlign,
wrap: overflowLayout.wrap,
text: textToRender,
fontSize: textFontSize,
align: textAlign,
align: overflowLayout.textAlign,
lineHeight: textLineHeight,
letterSpacing: textLetterSpacing,
fontFamily: konvaTextFontFamily,
fill: konvaTextFill,
width: fieldWidth - DEFAULT_TEXT_X_PADDING * 2,
height: fieldHeight,
width: overflowLayout.width,
height: overflowLayout.height,
} satisfies Partial<Konva.TextConfig>);
return fieldText;
return {
fieldText,
isLabel,
textToRender,
textFontSize,
textAlign,
textVerticalAlign,
textLineHeight,
textLetterSpacing,
};
};
export const renderGenericTextFieldElement = (
@@ -121,6 +162,8 @@ export const renderGenericTextFieldElement = (
options: RenderFieldElementOptions,
) => {
const { mode = 'edit', pageLayer, color } = options;
const { pageWidth, pageHeight } = options;
const fieldMeta = field.fieldMeta as GenericTextFieldTypeMetas | undefined;
const isFirstRender = !pageLayer.findOne(`#${field.renderId}`);
@@ -136,13 +179,20 @@ export const renderGenericTextFieldElement = (
// Render the field background and text.
const fieldRect = upsertFieldRect(field, options);
const fieldText = upsertFieldText(field, options);
const {
fieldText,
isLabel,
textToRender,
textFontSize,
textAlign,
textVerticalAlign,
textLineHeight,
textLetterSpacing,
} = upsertFieldText(field, options);
fieldGroup.add(fieldRect);
fieldGroup.add(fieldText);
// This is to keep the text inside the field at the same size
// when the field is resized. Without this the text would be stretched.
fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY();
@@ -154,17 +204,16 @@ export const renderGenericTextFieldElement = (
const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY;
// Update text dimensions
// During active transform, use crop dimensions (field bounds only).
fieldText.x(DEFAULT_TEXT_X_PADDING);
fieldText.y(0);
fieldText.width(rectWidth - DEFAULT_TEXT_X_PADDING * 2);
fieldText.height(rectHeight);
// Force Konva to recalculate text layout
fieldText.height();
fieldText.wrap('word');
fieldGroup.getLayer()?.batchDraw();
});
// Reset the text after transform has ended.
fieldGroup.on('transformend', () => {
fieldText.scaleX(1);
fieldText.scaleY(1);
@@ -172,12 +221,33 @@ export const renderGenericTextFieldElement = (
const rectWidth = fieldRect.width();
const rectHeight = fieldRect.height();
// Update text dimensions
fieldText.width(rectWidth - DEFAULT_TEXT_X_PADDING * 2);
fieldText.height(rectHeight);
// Recalculate overflow layout with new field dimensions.
const newOverflowLayout = calculateOverflowLayout({
overflowMode: resolveFieldOverflowMode(fieldMeta),
isLabel,
textToRender,
fontSize: textFontSize,
fontFamily: konvaTextFontFamily,
lineHeight: textLineHeight,
letterSpacing: textLetterSpacing,
textAlign,
verticalAlign: textVerticalAlign,
baseX: DEFAULT_TEXT_X_PADDING,
baseY: 0,
baseWidth: rectWidth - DEFAULT_TEXT_X_PADDING * 2,
baseHeight: rectHeight,
groupX: fieldGroup.x(),
groupY: fieldGroup.y(),
pageWidth,
pageHeight,
});
// Force Konva to recalculate text layout
fieldText.height();
fieldText.x(newOverflowLayout.x);
fieldText.y(newOverflowLayout.y);
fieldText.width(newOverflowLayout.width);
fieldText.height(newOverflowLayout.height);
fieldText.wrap(newOverflowLayout.wrap);
fieldText.verticalAlign(newOverflowLayout.verticalAlign);
fieldGroup.getLayer()?.batchDraw();
});
@@ -2,6 +2,9 @@ import Konva from 'konva';
import { DEFAULT_SIGNATURE_TEXT_FONT_SIZE } from '../../constants/pdf';
import { AppError } from '../../errors/app-error';
import type { TSignatureFieldMeta } from '../../types/field-meta';
import { resolveFieldOverflowMode } from '../../types/field-meta';
import { calculateOverflowLayout } from './calculate-overflow-layout';
import {
createFieldHoverInteraction,
upsertFieldGroup,
@@ -40,6 +43,18 @@ const getImageDimensions = (img: HTMLImageElement, fieldWidth: number, fieldHeig
};
};
type FieldSignature =
| {
node: Konva.Text;
isImageSignature: false;
isLabel: boolean;
}
| {
node: Konva.Image;
isImageSignature: true;
isLabel: boolean;
};
/**
* The pixel ratio used when caching the signature image as an offscreen bitmap.
*
@@ -108,10 +123,14 @@ const createSignatureImage = (
const createFieldSignature = (
field: FieldToRender,
options: RenderFieldElementOptions,
): Konva.Text | Konva.Image => {
): FieldSignature => {
const { pageWidth, pageHeight, mode = 'edit', translations } = options;
const { fieldWidth, fieldHeight } = calculateFieldPosition(field, pageWidth, pageHeight);
const { fieldX, fieldY, fieldWidth, fieldHeight } = calculateFieldPosition(
field,
pageWidth,
pageHeight,
);
const fontSize = field.fieldMeta?.fontSize || DEFAULT_SIGNATURE_TEXT_FONT_SIZE;
const fieldText = new Konva.Text({
@@ -140,7 +159,11 @@ const createFieldSignature = (
}
if (field.inserted && signature?.signatureImageAsBase64) {
return createSignatureImage(signature.signatureImageAsBase64, fieldWidth, fieldHeight);
return {
node: createSignatureImage(signature.signatureImageAsBase64, fieldWidth, fieldHeight),
isImageSignature: true,
isLabel: false,
};
}
}
@@ -157,31 +180,61 @@ const createFieldSignature = (
}
if (signature?.signatureImageAsBase64) {
return createSignatureImage(signature.signatureImageAsBase64, fieldWidth, fieldHeight);
return {
node: createSignatureImage(signature.signatureImageAsBase64, fieldWidth, fieldHeight),
isImageSignature: true,
isLabel: false,
};
}
}
fieldText.setAttrs({
x: textX,
y: textY,
const fieldMeta = field.fieldMeta as TSignatureFieldMeta | undefined;
// Whether we're rendering the field type name (like "Signature") vs actual signed content.
// Overflow should not apply to the label.
const isLabel = !signature?.typedSignature;
const overflowLayout = calculateOverflowLayout({
overflowMode: resolveFieldOverflowMode(fieldMeta),
isLabel,
textToRender,
fontSize,
fontFamily: 'Caveat, sans-serif',
lineHeight: 1,
letterSpacing: 0,
textAlign: 'center',
verticalAlign: 'middle',
wrap: 'char',
baseX: textX,
baseY: textY,
baseWidth: fieldWidth,
baseHeight: fieldHeight,
groupX: fieldX,
groupY: fieldY,
pageWidth,
pageHeight,
});
fieldText.setAttrs({
x: overflowLayout.x,
y: overflowLayout.y,
verticalAlign: overflowLayout.verticalAlign,
wrap: overflowLayout.wrap,
text: textToRender,
fontSize,
fontFamily: 'Caveat, sans-serif',
align: 'center',
width: fieldWidth,
height: fieldHeight,
align: overflowLayout.textAlign,
width: overflowLayout.width,
height: overflowLayout.height,
} satisfies Partial<Konva.TextConfig>);
return fieldText;
return { node: fieldText, isImageSignature: false, isLabel };
};
export const renderSignatureFieldElement = (
field: FieldToRender,
options: RenderFieldElementOptions,
) => {
const { mode = 'edit', pageLayer, color } = options;
const { mode = 'edit', pageLayer, pageWidth, pageHeight, color } = options;
const isFirstRender = !pageLayer.findOne(`#${field.renderId}`);
@@ -198,13 +251,11 @@ export const renderSignatureFieldElement = (
// Render the field background and text.
const fieldRect = upsertFieldRect(field, options);
const fieldSignature = createFieldSignature(field, options);
const { node: fieldSignature, isImageSignature, isLabel } = createFieldSignature(field, options);
fieldGroup.add(fieldRect);
fieldGroup.add(fieldSignature);
// This is to keep the text inside the field at the same size
// when the field is resized. Without this the text would be stretched.
fieldGroup.on('transform', () => {
const groupScaleX = fieldGroup.scaleX();
const groupScaleY = fieldGroup.scaleY();
@@ -216,17 +267,19 @@ export const renderSignatureFieldElement = (
const rectWidth = fieldRect.width() * groupScaleX;
const rectHeight = fieldRect.height() * groupScaleY;
// Update text dimensions
// During active transform, use crop dimensions (field bounds only).
if (!isImageSignature) {
fieldSignature.x(0);
fieldSignature.y(0);
fieldSignature.wrap('word');
}
fieldSignature.width(rectWidth);
fieldSignature.height(rectHeight);
// Force Konva to recalculate text layout
fieldSignature.height();
fieldGroup.getLayer()?.batchDraw();
});
// Reset the text after transform has ended.
fieldGroup.on('transformend', () => {
fieldSignature.scaleX(1);
fieldSignature.scaleY(1);
@@ -234,12 +287,39 @@ export const renderSignatureFieldElement = (
const rectWidth = fieldRect.width();
const rectHeight = fieldRect.height();
// Update text dimensions
fieldSignature.width(rectWidth); // Account for padding
fieldSignature.height(rectHeight);
if (!isImageSignature) {
const fieldMeta = field.fieldMeta as TSignatureFieldMeta | undefined;
// Force Konva to recalculate text layout
fieldSignature.height();
const newOverflowLayout = calculateOverflowLayout({
overflowMode: resolveFieldOverflowMode(fieldMeta),
isLabel,
textToRender: fieldSignature.text(),
fontSize: fieldSignature.fontSize(),
fontFamily: 'Caveat, sans-serif',
lineHeight: 1,
letterSpacing: 0,
textAlign: 'center',
verticalAlign: 'middle',
baseX: 0,
baseY: 0,
baseWidth: rectWidth,
baseHeight: rectHeight,
groupX: fieldGroup.x(),
groupY: fieldGroup.y(),
pageWidth,
pageHeight,
});
fieldSignature.x(newOverflowLayout.x);
fieldSignature.y(newOverflowLayout.y);
fieldSignature.width(newOverflowLayout.width);
fieldSignature.height(newOverflowLayout.height);
fieldSignature.wrap(newOverflowLayout.wrap);
fieldSignature.verticalAlign(newOverflowLayout.verticalAlign);
} else {
fieldSignature.width(rectWidth);
fieldSignature.height(rectHeight);
}
fieldGroup.getLayer()?.batchDraw();
});
+137
View File
@@ -3,6 +3,7 @@ import path from 'node:path';
import { ALIGNMENT_TEST_FIELDS } from '@documenso/app-tests/constants/field-alignment-pdf';
import { FIELD_META_TEST_FIELDS } from '@documenso/app-tests/constants/field-meta-pdf';
import { OVERFLOW_TEST_FIELDS } from '@documenso/app-tests/constants/field-overflow-pdf';
import { isBase64Image } from '@documenso/lib/constants/signatures';
import {
incrementDocumentId,
@@ -183,6 +184,22 @@ export const seedDatabase = async () => {
userId: adminUser.user.id,
teamId: adminUser.team.id,
}),
seedOverflowTestDocument({
userId: exampleUser.user.id,
teamId: exampleUser.team.id,
recipientName: exampleUser.user.name || '',
recipientEmail: exampleUser.user.email,
insertFields: false,
status: DocumentStatus.DRAFT,
}),
seedOverflowTestDocument({
userId: adminUser.user.id,
teamId: adminUser.team.id,
recipientName: adminUser.user.name || '',
recipientEmail: adminUser.user.email,
insertFields: false,
status: DocumentStatus.DRAFT,
}),
seedAlignmentTestDocument({
userId: exampleUser.user.id,
teamId: exampleUser.team.id,
@@ -443,3 +460,123 @@ export const seedAlignmentTestDocument = async ({
},
});
};
export const seedOverflowTestDocument = async ({
userId,
teamId,
recipientName,
recipientEmail,
insertFields,
status,
}: {
userId: number;
teamId: number;
recipientName: string;
recipientEmail: string;
insertFields: boolean;
status: DocumentStatus;
}) => {
const overflowPdf = fs
.readFileSync(path.join(__dirname, '../../../assets/field-overflow.pdf'))
.toString('base64');
const overflowDocumentData = await createDocumentData({ documentData: overflowPdf });
const secondaryId = await incrementDocumentId().then((v) => v.formattedDocumentId);
const documentMeta = await prisma.documentMeta.create({
data: {},
});
const createdEnvelope = await prisma.envelope.create({
data: {
id: prefixedId('envelope'),
secondaryId,
internalVersion: 2,
type: EnvelopeType.DOCUMENT,
documentMetaId: documentMeta.id,
source: DocumentSource.DOCUMENT,
title: 'Overflow Test',
status,
envelopeItems: {
createMany: {
data: [
{
id: prefixedId('envelope_item'),
title: 'field-overflow',
documentDataId: overflowDocumentData.id,
order: 1,
},
],
},
},
userId,
teamId,
recipients: {
create: {
name: recipientName,
email: recipientEmail,
token: nanoid(),
sendStatus: status === 'DRAFT' ? SendStatus.NOT_SENT : SendStatus.SENT,
signingStatus: status === 'COMPLETED' ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
readStatus: status !== 'DRAFT' ? ReadStatus.OPENED : ReadStatus.NOT_OPENED,
},
},
},
include: {
recipients: true,
envelopeItems: true,
},
});
const { id, recipients, envelopeItems } = createdEnvelope;
const recipientId = recipients[0].id;
const envelopeItemId = envelopeItems.find((item) => item.order === 1)?.id;
if (!envelopeItemId) {
throw new Error('Envelope item not found');
}
await Promise.all(
OVERFLOW_TEST_FIELDS.map(async (field) => {
// Use seedFieldMeta (full meta with all defaults) instead of fieldMeta
// (minimal meta for API testing) since the seed bypasses API validation.
const { fieldMeta: _fieldMeta, seedFieldMeta, ...fieldData } = field;
await prisma.field.create({
data: {
...fieldData,
fieldMeta: seedFieldMeta,
recipientId,
envelopeItemId,
envelopeId: id,
customText: insertFields ? field.customText : '',
inserted:
insertFields &&
((!seedFieldMeta?.readOnly && Boolean(field.customText)) || field.type === 'SIGNATURE'),
signature:
field.signature && insertFields
? {
create: {
recipientId,
signatureImageAsBase64: isBase64Image(field.signature) ? field.signature : null,
typedSignature: isBase64Image(field.signature) ? null : field.signature,
},
}
: undefined,
},
});
}),
);
return await prisma.envelope.findFirstOrThrow({
where: {
id: createdEnvelope.id,
},
include: {
recipients: true,
envelopeItems: true,
},
});
};
@@ -10,6 +10,8 @@ import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
import {
type TBaseFieldMeta as BaseFieldMeta,
type TCheckboxFieldMeta as CheckboxFieldMeta,
DEFAULT_DATE_OVERFLOW_MODE,
DEFAULT_EMAIL_OVERFLOW_MODE,
type TDateFieldMeta as DateFieldMeta,
type TDropdownFieldMeta as DropdownFieldMeta,
type TEmailFieldMeta as EmailFieldMeta,
@@ -83,12 +85,14 @@ const getDefaultState = (fieldType: FieldType): FieldMeta => {
type: 'email',
fontSize: 14,
textAlign: 'left',
overflow: DEFAULT_EMAIL_OVERFLOW_MODE,
};
case FieldType.DATE:
return {
type: 'date',
fontSize: 14,
textAlign: 'left',
overflow: DEFAULT_DATE_OVERFLOW_MODE,
};
case FieldType.TEXT:
return {
@@ -186,7 +190,6 @@ export const FieldAdvancedSettings = forwardRef<HTMLDivElement, FieldAdvancedSet
...parsedFieldMeta,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fieldMeta]);
const { scheduleSave } = useAutoSave(onAutoSave || (async () => {}));
@@ -1,7 +1,10 @@
import { Trans, useLingui } from '@lingui/react/macro';
import { validateFields as validateDateFields } from '@documenso/lib/advanced-fields-validation/validate-fields';
import { type TDateFieldMeta as DateFieldMeta } from '@documenso/lib/types/field-meta';
import {
DEFAULT_DATE_OVERFLOW_MODE,
type TDateFieldMeta as DateFieldMeta,
} from '@documenso/lib/types/field-meta';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import {
@@ -48,6 +51,7 @@ export const DateFieldAdvancedSettings = ({
const errors = validateDateFields({
fontSize,
overflow: fieldState.overflow ?? DEFAULT_DATE_OVERFLOW_MODE,
type: 'date',
});
@@ -64,7 +68,7 @@ export const DateFieldAdvancedSettings = ({
<Input
id="fontSize"
type="number"
className="bg-background mt-2"
className="mt-2 bg-background"
placeholder={t`Field font size`}
value={fieldState.fontSize}
onChange={(e) => handleInput('fontSize', e.target.value)}
@@ -82,7 +86,7 @@ export const DateFieldAdvancedSettings = ({
value={fieldState.textAlign}
onValueChange={(value) => handleInput('textAlign', value)}
>
<SelectTrigger className="bg-background mt-2">
<SelectTrigger className="mt-2 bg-background">
<SelectValue placeholder={t`Select text align`} />
</SelectTrigger>
@@ -1,7 +1,10 @@
import { Trans, useLingui } from '@lingui/react/macro';
import { validateFields as validateEmailFields } from '@documenso/lib/advanced-fields-validation/validate-fields';
import { type TEmailFieldMeta as EmailFieldMeta } from '@documenso/lib/types/field-meta';
import {
DEFAULT_EMAIL_OVERFLOW_MODE,
type TEmailFieldMeta as EmailFieldMeta,
} from '@documenso/lib/types/field-meta';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import {
@@ -30,6 +33,7 @@ export const EmailFieldAdvancedSettings = ({
const errors = validateEmailFields({
fontSize,
overflow: fieldState.overflow ?? DEFAULT_EMAIL_OVERFLOW_MODE,
type: 'email',
});
@@ -46,7 +50,7 @@ export const EmailFieldAdvancedSettings = ({
<Input
id="fontSize"
type="number"
className="bg-background mt-2"
className="mt-2 bg-background"
placeholder={t`Field font size`}
value={fieldState.fontSize}
onChange={(e) => handleInput('fontSize', e.target.value)}
@@ -64,7 +68,7 @@ export const EmailFieldAdvancedSettings = ({
value={fieldState.textAlign}
onValueChange={(value) => handleInput('textAlign', value)}
>
<SelectTrigger className="bg-background mt-2">
<SelectTrigger className="mt-2 bg-background">
<SelectValue placeholder={t`Select text align`} />
</SelectTrigger>
+260
View File
@@ -0,0 +1,260 @@
// scripts/generate-overflow-test-pdf.mjs
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// --- Helper functions to generate HTML for each page ---
function box(left, top, width, height) {
return `left:${left}%;top:${top}%;width:${width}%;height:${height}%;`;
}
function labelStyle(left, top) {
return `left:${left}%;top:${top}%;`;
}
function makeBox(left, top, width, height, label, { labelBelow = false } = {}) {
const labelTop = labelBelow ? top + height + 0.5 : top - 1.5;
return `
<div class="label" style="${labelStyle(left, labelTop)}">${label}</div>
<div class="box" style="${box(left, top, width, height)}"></div>`;
}
// --- Pages 1-2: Date / Email (3×3 multi-line grid with text align variants) ---
function makeFieldOverflowPageWithGrid(title) {
const startX = 10;
const boxWidth = 35;
// Section A: Single-line height
const slHeight = 1.75;
const slStartY = 15;
const slRowSpacing = 8;
const sectionARows = [
{ y: slStartY, label: 'M_AUTO TA_LEFT VA_MIDDLE' },
{ y: slStartY + slRowSpacing, label: 'M_AUTO TA_LEFT VA_MIDDLE' },
{ y: slStartY + 2 * slRowSpacing, label: 'M_AUTO TA_LEFT VA_MIDDLE' },
];
// Section B: Multi-line height — 3×3 grid
// Rows: TA_LEFT, TA_CENTER, TA_RIGHT
// Columns: short, medium, long text
const mlHeight = 12;
const mlBoxWidth = 30;
const mlColumnX = [2.5, 35, 67.5];
const mlRowY = [45, 63, 83];
const mlTextAligns = ['LEFT', 'CENTER', 'RIGHT'];
let content = `
<div class="title" style="top:3%;left:10%;">${title}</div>`;
for (const r of sectionARows) {
content += makeBox(startX, r.y, boxWidth, slHeight, r.label);
}
for (let ri = 0; ri < 3; ri++) {
for (let ci = 0; ci < 3; ci++) {
const label = `M_AUTO TA_${mlTextAligns[ri]} VA_MIDDLE`;
content += makeBox(mlColumnX[ci], mlRowY[ri], mlBoxWidth, mlHeight, label);
}
}
return content;
}
// --- Page 3: Signature (stacked multi-line, no text align control) ---
function makeSignatureOverflowPage(title) {
const startX = 10;
const boxWidth = 35;
// Section A: Single-line height
const slHeight = 1.75;
const slStartY = 15;
const slRowSpacing = 8;
const sectionARows = [
{ y: slStartY, label: 'M_AUTO TA_CENTER VA_MIDDLE' },
{ y: slStartY + slRowSpacing, label: 'M_AUTO TA_CENTER VA_MIDDLE' },
{ y: slStartY + 2 * slRowSpacing, label: 'M_AUTO TA_CENTER VA_MIDDLE' },
];
// Section B: Multi-line height — stacked single column
const mlHeight = 12;
const mlStartY = 45;
const mlRowSpacing = 18;
const sectionBRows = [
{ y: mlStartY, label: 'M_AUTO TA_CENTER VA_MIDDLE' },
{ y: mlStartY + mlRowSpacing, label: 'M_AUTO TA_CENTER VA_MIDDLE' },
{ y: mlStartY + 2 * mlRowSpacing, label: 'M_AUTO TA_CENTER VA_MIDDLE' },
];
let content = `
<div class="title" style="top:3%;left:10%;">${title}</div>`;
for (const r of sectionARows) {
content += makeBox(startX, r.y, boxWidth, slHeight, r.label);
}
for (const r of sectionBRows) {
content += makeBox(startX, r.y, boxWidth, mlHeight, r.label);
}
return content;
}
// --- Pages 4-5: Text Field Auto Mode ---
function makeTextAutoPage(title, boxHeight, isSingleLine) {
const boxWidth = 28;
const columns = [
{ col: 0, x: 5, textAlign: 'left' },
{ col: 1, x: 35.5, textAlign: 'center' },
{ col: 2, x: 66, textAlign: 'right' },
];
const verticalAligns = ['top', 'middle', 'bottom'];
let content = `
<div class="title" style="top:3%;left:5%;">${title}</div>`;
if (isSingleLine) {
// Single-line: stagger all 9 items evenly down the page.
// Each item gets its own Y position to avoid horizontal overflow collision.
const itemCount = 9; // 3 rows × 3 columns
const startY = 10;
const endY = 92;
const spacing = (endY - startY) / (itemCount - 1);
let itemIndex = 0;
for (let ri = 0; ri < 3; ri++) {
for (let ci = 0; ci < columns.length; ci++) {
const col = columns[ci];
const y = startY + itemIndex * spacing;
const label = `M_AUTO TA_${col.textAlign.toUpperCase()} VA_${verticalAligns[ri].toUpperCase()}`;
content += makeBox(col.x, y, boxWidth, boxHeight, label);
itemIndex++;
}
}
} else {
// Multi-line: 3 rows evenly spaced, bottom row near page bottom.
// Box is 12% tall. Top of last box at 80% so bottom edge is at 92%.
const startY = 10;
const lastRowY = 80; // 80 + 12 = 92% (page bottom with padding)
const midY = (startY + lastRowY) / 2; // 45%
const rowYPositions = [startY, midY, lastRowY];
for (let ri = 0; ri < 3; ri++) {
const labelBelow = verticalAligns[ri] === 'bottom';
for (const col of columns) {
const label = `M_AUTO TA_${col.textAlign.toUpperCase()} VA_${verticalAligns[ri].toUpperCase()}`;
content += makeBox(col.x, rowYPositions[ri], boxWidth, boxHeight, label, { labelBelow });
}
}
}
return content;
}
// --- Page 6: Explicit Modes ---
function makePage6() {
const boxWidth = 25;
let content = `
<div class="title" style="top:3%;left:5%;">Text Field Explicit Modes</div>
`;
// Section A: Horizontal mode — centered on page, staggered vertically
const centeredX = (100 - boxWidth) / 2; // 37.5%
const horizontalRows = [
{ x: centeredX, y: 15, label: 'M_HORIZONTAL TA_LEFT VA_TOP' },
{ x: centeredX, y: 21, label: 'M_HORIZONTAL TA_CENTER VA_TOP' },
{ x: centeredX, y: 27, label: 'M_HORIZONTAL TA_RIGHT VA_TOP' },
];
for (const r of horizontalRows) {
content += makeBox(r.x, r.y, boxWidth, 1.75, r.label);
}
// Section B: Vertical mode — place side by side so vertical overflow has room below
content += '';
const verticalCols = [
{ x: 5, y: 43, label: 'M_VERTICAL TA_LEFT VA_TOP', labelBelow: false },
{ x: 37.5, y: 43, label: 'M_VERTICAL TA_LEFT VA_MIDDLE', labelBelow: false },
{ x: 70, y: 43, label: 'M_VERTICAL TA_LEFT VA_BOTTOM', labelBelow: true },
];
for (const c of verticalCols) {
content += makeBox(c.x, c.y, boxWidth, 12, c.label, { labelBelow: c.labelBelow });
}
return content;
}
// --- Page 7: Crop Mode ---
function makePage7() {
return `
<div class="title" style="top:3%;left:10%;">Crop Mode (no overflow)</div>
${makeBox(10, 15, 25, 1.75, 'M_CROP TA_LEFT VA_TOP')}
${makeBox(10, 30, 25, 12, 'M_CROP TA_LEFT VA_TOP')}`;
}
// --- Assemble all pages ---
function buildPage(contentFn) {
return `<div class="page">${typeof contentFn === 'string' ? contentFn : contentFn}</div>`;
}
const html = `<!DOCTYPE html>
<html>
<head>
<style>
@page { size: A4; margin: 0; }
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: sans-serif; font-size: 10px; }
.page { width: 210mm; height: 297mm; position: relative; page-break-after: always; }
.page:last-child { page-break-after: auto; }
.title { position: absolute; font-size: 18px; font-weight: bold; }
.section-label { position: absolute; font-size: 12px; font-weight: bold; color: #666; }
.box { position: absolute; border: 1px solid black; box-sizing: border-box; }
.label { position: absolute; font-size: 9px; color: red; font-weight: bold; z-index: 9999; }
</style>
</head>
<body>
${buildPage(makeFieldOverflowPageWithGrid('Date Field Overflow Tests'))}
${buildPage(makeFieldOverflowPageWithGrid('Email Field Overflow Tests'))}
${buildPage(makeSignatureOverflowPage('Signature Field Overflow Tests'))}
${buildPage(makeTextAutoPage('Text Field Auto - Single-line Height', 1.75, true))}
${buildPage(makeTextAutoPage('Text Field Auto - Multi-line Height', 12, false))}
${buildPage(makeTextAutoPage('Text Field Auto - Multi-line Height Overflow', 12, false))}
${buildPage(makePage6())}
${buildPage(makePage7())}
</body>
</html>`;
async function main() {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'networkidle' });
const outputPath = path.join(__dirname, '../assets/field-overflow.pdf');
await page.pdf({
path: outputPath,
format: 'A4',
printBackground: true,
margin: { top: '0', right: '0', bottom: '0', left: '0' },
});
await browser.close();
console.log(`PDF generated: assets/field-overflow.pdf`);
}
main().catch(console.error);