@servicetitan/dte-pdf-editor 1.93.3 → 1.95.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -2
- package/dist/components/pdf-fields-overlay/pdf-overlay-field-fillable.d.ts.map +1 -1
- package/dist/components/pdf-fields-overlay/pdf-overlay-field-fillable.js +4 -6
- package/dist/components/pdf-fields-overlay/pdf-overlay-field-fillable.js.map +1 -1
- package/dist/components/pdf-view/pdf-view-fillable.d.ts.map +1 -1
- package/dist/components/pdf-view/pdf-view-fillable.js +27 -4
- package/dist/components/pdf-view/pdf-view-fillable.js.map +1 -1
- package/dist/components/shared/char-limit-indicator.d.ts +8 -0
- package/dist/components/shared/char-limit-indicator.d.ts.map +1 -0
- package/dist/components/shared/char-limit-indicator.js +9 -0
- package/dist/components/shared/char-limit-indicator.js.map +1 -0
- package/dist/components/shared/index.d.ts +1 -0
- package/dist/components/shared/index.d.ts.map +1 -1
- package/dist/components/shared/index.js +1 -0
- package/dist/components/shared/index.js.map +1 -1
- package/dist/styles/pdf-field-overlay.css +9 -2
- package/dist/styles/pdf-field.css +22 -1
- package/dist/utils/field/field-text-box.utils.d.ts +31 -0
- package/dist/utils/field/field-text-box.utils.d.ts.map +1 -0
- package/dist/utils/field/field-text-box.utils.js +102 -0
- package/dist/utils/field/field-text-box.utils.js.map +1 -0
- package/dist/utils/field/fit-field-text.utils.d.ts +13 -0
- package/dist/utils/field/fit-field-text.utils.d.ts.map +1 -0
- package/dist/utils/field/fit-field-text.utils.js +44 -0
- package/dist/utils/field/fit-field-text.utils.js.map +1 -0
- package/dist/utils/field/get-field-max-length.utils.d.ts +38 -0
- package/dist/utils/field/get-field-max-length.utils.d.ts.map +1 -0
- package/dist/utils/field/get-field-max-length.utils.js +166 -0
- package/dist/utils/field/get-field-max-length.utils.js.map +1 -0
- package/dist/utils/field/index.d.ts +2 -0
- package/dist/utils/field/index.d.ts.map +1 -1
- package/dist/utils/field/index.js +2 -0
- package/dist/utils/field/index.js.map +1 -1
- package/dist/utils/field/text-wrap.utils.d.ts +26 -0
- package/dist/utils/field/text-wrap.utils.d.ts.map +1 -0
- package/dist/utils/field/text-wrap.utils.js +53 -0
- package/dist/utils/field/text-wrap.utils.js.map +1 -0
- package/package.json +1 -1
- package/src/components/pdf-fields-overlay/pdf-overlay-field-fillable.tsx +41 -28
- package/src/components/pdf-view/pdf-view-fillable.tsx +82 -36
- package/src/components/shared/char-limit-indicator.tsx +23 -0
- package/src/components/shared/index.ts +1 -0
- package/src/styles/pdf-field-overlay.css +9 -2
- package/src/styles/pdf-field.css +22 -1
- package/src/utils/field/__tests__/fit-field-text.utils.test.ts +224 -0
- package/src/utils/field/__tests__/get-field-max-length.utils.test.ts +751 -0
- package/src/utils/field/__tests__/text-wrap.utils.test.ts +181 -0
- package/src/utils/field/field-text-box.utils.ts +143 -0
- package/src/utils/field/fit-field-text.utils.ts +61 -0
- package/src/utils/field/get-field-max-length.utils.ts +211 -0
- package/src/utils/field/index.ts +2 -0
- package/src/utils/field/text-wrap.utils.ts +78 -0
|
@@ -0,0 +1,751 @@
|
|
|
1
|
+
import { FieldTypeEnum, type PdfField } from '../../../interface/types';
|
|
2
|
+
import { pointsToPixels } from '../../pdf';
|
|
3
|
+
import { estimateTextCapacity, getFieldMaxLength } from '../get-field-max-length.utils';
|
|
4
|
+
import { countWrappedLines, type TextMeasurer } from '../text-wrap.utils';
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* Glyph advance widths (1000 units per em) for printable ASCII, U+0020..U+007E, measured from
|
|
8
|
+
* Arial, Arial Bold and Times New Roman. They back a fake canvas below, so the tests run against
|
|
9
|
+
* real proportional metrics (an `i` is a fifth of a `W`) and stay identical on every machine.
|
|
10
|
+
*/
|
|
11
|
+
const ARIAL_WIDTHS = [
|
|
12
|
+
278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278, 556, 556, 556,
|
|
13
|
+
556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556, 1015, 667, 667, 722, 722, 667,
|
|
14
|
+
611, 778, 722, 278, 500, 667, 556, 833, 722, 778, 667, 778, 722, 667, 611, 722, 667, 944, 667,
|
|
15
|
+
667, 611, 278, 278, 278, 469, 556, 333, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500,
|
|
16
|
+
222, 833, 556, 556, 556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584,
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const ARIAL_BOLD_WIDTHS = [
|
|
20
|
+
278, 333, 474, 556, 556, 889, 722, 238, 333, 333, 389, 584, 278, 333, 278, 278, 556, 556, 556,
|
|
21
|
+
556, 556, 556, 556, 556, 556, 556, 333, 333, 584, 584, 584, 611, 975, 722, 722, 722, 722, 667,
|
|
22
|
+
611, 778, 722, 278, 556, 722, 611, 833, 722, 778, 667, 778, 722, 667, 611, 722, 667, 944, 667,
|
|
23
|
+
667, 611, 333, 278, 333, 584, 556, 333, 556, 611, 556, 611, 556, 333, 611, 611, 278, 278, 556,
|
|
24
|
+
278, 889, 611, 611, 611, 611, 389, 556, 333, 611, 556, 778, 556, 556, 500, 389, 280, 389, 584,
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const TIMES_WIDTHS = [
|
|
28
|
+
250, 333, 408, 500, 500, 833, 778, 180, 333, 333, 500, 564, 250, 333, 250, 278, 500, 500, 500,
|
|
29
|
+
500, 500, 500, 500, 500, 500, 500, 278, 278, 564, 564, 564, 444, 921, 722, 667, 667, 722, 611,
|
|
30
|
+
556, 722, 722, 333, 389, 722, 611, 889, 722, 722, 556, 722, 667, 556, 611, 722, 722, 944, 722,
|
|
31
|
+
722, 611, 333, 278, 333, 469, 500, 333, 444, 500, 444, 500, 444, 333, 500, 500, 278, 278, 500,
|
|
32
|
+
278, 778, 500, 500, 500, 500, 333, 389, 278, 500, 500, 722, 500, 500, 444, 480, 200, 480, 541,
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
const COURIER_WIDTH = 600;
|
|
36
|
+
const CJK_WIDTH = 1000;
|
|
37
|
+
const OTHER_WIDTH = 556;
|
|
38
|
+
|
|
39
|
+
type FontMetrics = (char: string) => number;
|
|
40
|
+
|
|
41
|
+
const tableMetrics = (widths: number[]): FontMetrics => {
|
|
42
|
+
return char => {
|
|
43
|
+
const code = char.codePointAt(0)!;
|
|
44
|
+
if (code >= 0x20 && code <= 0x7e) {
|
|
45
|
+
return widths[code - 0x20];
|
|
46
|
+
}
|
|
47
|
+
return code >= 0x2e80 ? CJK_WIDTH : OTHER_WIDTH;
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const METRICS = {
|
|
52
|
+
arial: tableMetrics(ARIAL_WIDTHS),
|
|
53
|
+
arialBold: tableMetrics(ARIAL_BOLD_WIDTHS),
|
|
54
|
+
times: tableMetrics(TIMES_WIDTHS),
|
|
55
|
+
courier: () => COURIER_WIDTH,
|
|
56
|
+
} satisfies Record<string, FontMetrics>;
|
|
57
|
+
|
|
58
|
+
type FontKey = keyof typeof METRICS;
|
|
59
|
+
|
|
60
|
+
/** Resolves the font a canvas context was given, e.g. `bold 12.8px Times New Roman, serif`. */
|
|
61
|
+
const parseCanvasFont = (font: string) => {
|
|
62
|
+
const [, weight, size, family] = /^(normal|bold)\s+([\d.]+)px\s+(.+)$/.exec(font)!;
|
|
63
|
+
const familyName = family.toLowerCase();
|
|
64
|
+
let key: FontKey = 'arial';
|
|
65
|
+
if (familyName.includes('courier')) {
|
|
66
|
+
key = 'courier';
|
|
67
|
+
} else if (familyName.includes('times')) {
|
|
68
|
+
key = 'times';
|
|
69
|
+
} else if (weight === 'bold') {
|
|
70
|
+
key = 'arialBold';
|
|
71
|
+
}
|
|
72
|
+
return { key, sizePx: Number(size) };
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const createFakeCanvasContext = () => {
|
|
76
|
+
let font = '';
|
|
77
|
+
return {
|
|
78
|
+
get font() {
|
|
79
|
+
return font;
|
|
80
|
+
},
|
|
81
|
+
set font(value: string) {
|
|
82
|
+
font = value;
|
|
83
|
+
},
|
|
84
|
+
measureText: (text: string) => {
|
|
85
|
+
const { key, sizePx } = parseCanvasFont(font);
|
|
86
|
+
const em = Array.from(text).reduce((sum, char) => sum + METRICS[key](char) / 1000, 0);
|
|
87
|
+
return { width: em * sizePx };
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const measurerFor = (key: FontKey, sizePx: number): TextMeasurer => {
|
|
93
|
+
return text => {
|
|
94
|
+
return Array.from(text).reduce(
|
|
95
|
+
(sum, char) => sum + (METRICS[key](char) / 1000) * sizePx,
|
|
96
|
+
0,
|
|
97
|
+
);
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** How many leading characters of `text` (repeated as needed) fit in `width` on one line. */
|
|
102
|
+
const countCharsThatFit = (text: string, width: number, measure: TextMeasurer) => {
|
|
103
|
+
const source = text.repeat(40);
|
|
104
|
+
let count = 0;
|
|
105
|
+
while (count < source.length && measure(source.slice(0, count + 1)) <= width) {
|
|
106
|
+
count += 1;
|
|
107
|
+
}
|
|
108
|
+
return count;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const mean = (values: number[]) => values.reduce((sum, v) => sum + v, 0) / values.length;
|
|
112
|
+
|
|
113
|
+
/** Things people really type into a single-line field: names, addresses, emails, notes. */
|
|
114
|
+
const REALISTIC_ENTRIES = [
|
|
115
|
+
'Jonathan Alexander Smith',
|
|
116
|
+
'1234 Elm Street, Apartment 5B',
|
|
117
|
+
'jonathan.smith@example.com',
|
|
118
|
+
'Maria Garcia Lopez',
|
|
119
|
+
'Springfield, IL 62704',
|
|
120
|
+
'Customer approved estimate #48213 for $1,250.00',
|
|
121
|
+
'Replace the capacitor and clean the condenser coils',
|
|
122
|
+
'Call before arriving; dog in backyard',
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
const PARAGRAPHS = [
|
|
126
|
+
'The customer reported that the air conditioner stopped cooling on Tuesday morning. The technician inspected the outdoor unit, found a failed capacitor and a dirty condenser coil, replaced the capacitor, and cleaned the coil. ',
|
|
127
|
+
'The Contractor shall perform the work in a professional manner and in accordance with all applicable laws and regulations. Payment is due within thirty days of the invoice date. ',
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
const buildField = (overrides: Partial<PdfField> = {}): PdfField => ({
|
|
131
|
+
id: 'field-1',
|
|
132
|
+
type: FieldTypeEnum.fillable,
|
|
133
|
+
subType: 'text',
|
|
134
|
+
x: 0,
|
|
135
|
+
y: 0,
|
|
136
|
+
page: 1,
|
|
137
|
+
label: 'Label',
|
|
138
|
+
width: 200,
|
|
139
|
+
height: 25,
|
|
140
|
+
...overrides,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const buildStyles = (
|
|
144
|
+
fontSize: number,
|
|
145
|
+
overrides: NonNullable<PdfField['styles']> = {},
|
|
146
|
+
): NonNullable<PdfField['styles']> => ({ fontSize, ...overrides });
|
|
147
|
+
|
|
148
|
+
const px = (points: number) => pointsToPixels(points);
|
|
149
|
+
|
|
150
|
+
/** Chrome keeps this much of a number input free of text for its (hidden) spin button. */
|
|
151
|
+
const SPIN_BUTTON_PX = 15;
|
|
152
|
+
|
|
153
|
+
describe('getFieldMaxLength', () => {
|
|
154
|
+
let getContext: jest.SpyInstance;
|
|
155
|
+
|
|
156
|
+
const subject = (field: PdfField) => getFieldMaxLength(field);
|
|
157
|
+
|
|
158
|
+
beforeAll(() => {
|
|
159
|
+
getContext = jest
|
|
160
|
+
.spyOn(HTMLCanvasElement.prototype, 'getContext')
|
|
161
|
+
.mockImplementation(() => createFakeCanvasContext() as unknown as RenderingContext);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
afterAll(() => {
|
|
165
|
+
getContext.mockRestore();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe('single-line text', () => {
|
|
169
|
+
test('is the number of typical characters that fit the width', () => {
|
|
170
|
+
const field = buildField({ width: 200 });
|
|
171
|
+
const measure = measurerFor('arial', px(8));
|
|
172
|
+
const actual = REALISTIC_ENTRIES.map(entry => countCharsThatFit(entry, 200, measure));
|
|
173
|
+
|
|
174
|
+
expect(Math.abs(subject(field)! - mean(actual))).toBeLessThanOrEqual(1.5);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('grows in proportion to the width', () => {
|
|
178
|
+
const small = subject(buildField({ width: 60 }))!;
|
|
179
|
+
const medium = subject(buildField({ width: 200 }))!;
|
|
180
|
+
const large = subject(buildField({ width: 800 }))!;
|
|
181
|
+
|
|
182
|
+
expect(small).toBeLessThan(medium);
|
|
183
|
+
expect(medium).toBeLessThan(large);
|
|
184
|
+
expect(Math.abs(large - 4 * medium)).toBeLessThanOrEqual(4);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('never returns less than one character, even in a sliver of a field', () => {
|
|
188
|
+
expect(subject(buildField({ width: 2 }))).toBe(1);
|
|
189
|
+
expect(subject(buildField({ width: 0 }))).toBe(1);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('is not affected by the field height', () => {
|
|
193
|
+
expect(subject(buildField({ height: 10 }))).toBe(subject(buildField({ height: 400 })));
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('shrinks as the font size grows, roughly inversely', () => {
|
|
197
|
+
const small = subject(buildField({ styles: buildStyles(6) }))!;
|
|
198
|
+
const normal = subject(buildField({ styles: buildStyles(8) }))!;
|
|
199
|
+
const large = subject(buildField({ styles: buildStyles(16) }))!;
|
|
200
|
+
|
|
201
|
+
expect(small).toBeGreaterThan(normal);
|
|
202
|
+
expect(normal).toBeGreaterThan(large);
|
|
203
|
+
expect(Math.abs(normal - 2 * large)).toBeLessThanOrEqual(2);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('shrinks for bold text, which has wider glyphs', () => {
|
|
207
|
+
const regular = subject(buildField({ styles: buildStyles(8) }))!;
|
|
208
|
+
const bold = subject(buildField({ styles: buildStyles(8, { fontWeight: 'bold' }) }))!;
|
|
209
|
+
|
|
210
|
+
expect(bold).toBeLessThan(regular);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('follows the font family: narrow serif > sans-serif > monospace', () => {
|
|
214
|
+
const forFamily = (fontFamily: string) => {
|
|
215
|
+
return subject(buildField({ styles: buildStyles(8, { fontFamily }) }))!;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const times = forFamily('Times New Roman, serif');
|
|
219
|
+
const arial = forFamily('Arial, Helvetica, sans-serif');
|
|
220
|
+
const courier = forFamily('Courier New, monospace');
|
|
221
|
+
|
|
222
|
+
expect(times).toBeGreaterThan(arial);
|
|
223
|
+
expect(arial).toBeGreaterThan(courier);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test('is exact for a monospace font, where every glyph has the same width', () => {
|
|
227
|
+
const field = buildField({
|
|
228
|
+
width: 200,
|
|
229
|
+
styles: buildStyles(8, { fontFamily: 'Courier New, monospace' }),
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
expect(subject(field)).toBe(Math.floor(200 / (0.6 * px(8))));
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('measures with the field font, size and weight', () => {
|
|
236
|
+
subject(
|
|
237
|
+
buildField({
|
|
238
|
+
width: 321,
|
|
239
|
+
styles: buildStyles(11, { fontWeight: 'bold', fontFamily: 'Times New Roman' }),
|
|
240
|
+
}),
|
|
241
|
+
);
|
|
242
|
+
const ctx = getContext.mock.results.at(-1)!.value as { font: string };
|
|
243
|
+
|
|
244
|
+
expect(ctx.font).toBe(`bold ${px(11)}px Times New Roman`);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('follows the width when a field is resized', () => {
|
|
248
|
+
const widths = [40, 80, 120, 160, 200, 240, 400, 800];
|
|
249
|
+
const results = widths.map(width => subject(buildField({ width }))!);
|
|
250
|
+
|
|
251
|
+
expect(results).toEqual([...results].sort((a, b) => a - b));
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe('number', () => {
|
|
256
|
+
test('fits the widest typeable glyph, so any value of that length fits', () => {
|
|
257
|
+
const field = buildField({ subType: 'number', width: 200 });
|
|
258
|
+
const measure = measurerFor('arial', px(8));
|
|
259
|
+
const maxLength = subject(field)!;
|
|
260
|
+
|
|
261
|
+
const usableWidth = 200 - SPIN_BUTTON_PX;
|
|
262
|
+
|
|
263
|
+
// Arial digits are 0.556em, the widest of the digits, `.` and `-`
|
|
264
|
+
expect(maxLength).toBe(Math.floor(usableWidth / (0.556 * px(8))));
|
|
265
|
+
|
|
266
|
+
for (const pattern of ['0123456789', '-0.123456789', '8888888888', '99.99-99.99']) {
|
|
267
|
+
const atLimit = pattern.repeat(10).slice(0, maxLength);
|
|
268
|
+
expect(measure(atLimit)).toBeLessThanOrEqual(usableWidth);
|
|
269
|
+
}
|
|
270
|
+
expect(measure('0'.repeat(maxLength + 1))).toBeGreaterThan(usableWidth);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test('holds integers, decimals and negative numbers of that length', () => {
|
|
274
|
+
const field = buildField({ subType: 'number', width: 120 });
|
|
275
|
+
const measure = measurerFor('arial', px(8));
|
|
276
|
+
const maxLength = subject(field)!;
|
|
277
|
+
|
|
278
|
+
const integer = '7'.repeat(maxLength);
|
|
279
|
+
const decimal = `${'7'.repeat(maxLength - 3)}.77`;
|
|
280
|
+
const negative = `-${'7'.repeat(maxLength - 1)}`;
|
|
281
|
+
|
|
282
|
+
for (const value of [integer, decimal, negative]) {
|
|
283
|
+
expect(value).toHaveLength(maxLength);
|
|
284
|
+
expect(measure(value)).toBeLessThanOrEqual(120 - SPIN_BUTTON_PX);
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test('scales to large numbers in wide fields', () => {
|
|
289
|
+
expect(subject(buildField({ subType: 'number', width: 900 }))).toBe(
|
|
290
|
+
Math.floor((900 - SPIN_BUTTON_PX) / (0.556 * px(8))),
|
|
291
|
+
);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test('uses digit widths, not the average text width', () => {
|
|
295
|
+
const number = subject(buildField({ subType: 'number', width: 200 }))!;
|
|
296
|
+
const text = subject(buildField({ subType: 'text', width: 200 }))!;
|
|
297
|
+
|
|
298
|
+
expect(number).toBeLessThan(text);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test('follows font size and family', () => {
|
|
302
|
+
const forStyles = (styles: PdfField['styles']) => {
|
|
303
|
+
return subject(buildField({ subType: 'number', styles }))!;
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
expect(forStyles(buildStyles(16))).toBeLessThan(forStyles(buildStyles(8)));
|
|
307
|
+
expect(forStyles(buildStyles(8, { fontFamily: 'Courier New' }))).toBe(
|
|
308
|
+
Math.floor((200 - SPIN_BUTTON_PX) / (0.6 * px(8))),
|
|
309
|
+
);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test('leaves room for the number input spin button, which text is clipped by', () => {
|
|
313
|
+
const withoutGutter = Math.floor(200 / (0.556 * px(8)));
|
|
314
|
+
|
|
315
|
+
expect(subject(buildField({ subType: 'number', width: 200 }))).toBeLessThan(
|
|
316
|
+
withoutGutter,
|
|
317
|
+
);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test('is not affected by the field height', () => {
|
|
321
|
+
expect(subject(buildField({ subType: 'number', height: 10 }))).toBe(
|
|
322
|
+
subject(buildField({ subType: 'number', height: 300 })),
|
|
323
|
+
);
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
describe('multiline text', () => {
|
|
328
|
+
const multiline = (overrides: Partial<PdfField> = {}) => {
|
|
329
|
+
return buildField({ multiline: true, ...overrides });
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
test('a field shorter than one line still holds one line of text', () => {
|
|
333
|
+
const tiny = subject(multiline({ height: 5 }))!;
|
|
334
|
+
const oneLine = subject(multiline({ height: 12 }))!;
|
|
335
|
+
|
|
336
|
+
expect(tiny).toBe(oneLine);
|
|
337
|
+
expect(tiny).toBeGreaterThan(0);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test('one line of a multiline field holds no more than a single-line field', () => {
|
|
341
|
+
const wrapped = subject(multiline({ height: 12 }))!;
|
|
342
|
+
const unwrapped = subject(buildField())!;
|
|
343
|
+
|
|
344
|
+
expect(wrapped).toBeLessThanOrEqual(unwrapped);
|
|
345
|
+
expect(wrapped).toBeGreaterThan(unwrapped * 0.7);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test('grows with the number of lines that fit the height', () => {
|
|
349
|
+
const lines = [1, 2, 4, 8].map(count => {
|
|
350
|
+
return subject(multiline({ height: Math.ceil(count * px(8)) + 1 }))!;
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
expect(lines).toEqual([...lines].sort((a, b) => a - b));
|
|
354
|
+
expect(Math.abs(lines[3] - 8 * lines[0])).toBeLessThan(0.15 * lines[3]);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test('does not count a partially visible line', () => {
|
|
358
|
+
const fourLines = Math.floor(4 * px(8));
|
|
359
|
+
const nearlyFive = Math.floor(5 * px(8)) - 1;
|
|
360
|
+
|
|
361
|
+
expect(subject(multiline({ height: nearlyFive }))).toBe(
|
|
362
|
+
subject(multiline({ height: fourLines + 1 })),
|
|
363
|
+
);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test('grows with the width', () => {
|
|
367
|
+
const narrow = subject(multiline({ width: 100, height: 100 }))!;
|
|
368
|
+
const wide = subject(multiline({ width: 400, height: 100 }))!;
|
|
369
|
+
|
|
370
|
+
expect(wide).toBeGreaterThan(3 * narrow);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test('is a large capacity for a large field', () => {
|
|
374
|
+
const small = subject(multiline({ width: 100, height: 30 }))!;
|
|
375
|
+
const large = subject(multiline({ width: 900, height: 600 }))!;
|
|
376
|
+
|
|
377
|
+
expect(large).toBeGreaterThan(100 * small);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test('loses less to wrapping the wider the field gets', () => {
|
|
381
|
+
const efficiency = (width: number) => {
|
|
382
|
+
const wrapped = subject(multiline({ width, height: 1000 }))!;
|
|
383
|
+
const rawPerLine = subject(buildField({ width }))!;
|
|
384
|
+
return wrapped / (rawPerLine * Math.floor(1000 / px(8)));
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
expect(efficiency(80)).toBeLessThan(efficiency(300));
|
|
388
|
+
expect(efficiency(300)).toBeLessThan(1);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test('accounts for the space word wrapping wastes, unlike chars-per-line x lines', () => {
|
|
392
|
+
const field = multiline({ width: 100, height: 100 });
|
|
393
|
+
const naive = subject(buildField({ width: 100 }))! * Math.floor(100 / px(8));
|
|
394
|
+
|
|
395
|
+
expect(subject(field)!).toBeLessThan(naive);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test('shrinks by about 4x when the font size doubles: fewer lines, shorter lines', () => {
|
|
399
|
+
const small = subject(multiline({ width: 300, height: 200, styles: buildStyles(6) }))!;
|
|
400
|
+
const large = subject(multiline({ width: 300, height: 200, styles: buildStyles(12) }))!;
|
|
401
|
+
|
|
402
|
+
expect(small / large).toBeGreaterThan(3.2);
|
|
403
|
+
expect(small / large).toBeLessThan(4.8);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test('shrinks for bold text and follows the font family', () => {
|
|
407
|
+
const forStyles = (styles: PdfField['styles']) => {
|
|
408
|
+
return subject(multiline({ width: 300, height: 120, styles }))!;
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
expect(forStyles(buildStyles(8, { fontWeight: 'bold' }))).toBeLessThan(
|
|
412
|
+
forStyles(buildStyles(8)),
|
|
413
|
+
);
|
|
414
|
+
expect(forStyles(buildStyles(8, { fontFamily: 'Courier New' }))).toBeLessThan(
|
|
415
|
+
forStyles(buildStyles(8)),
|
|
416
|
+
);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
test.each([
|
|
420
|
+
{ width: 120, height: 60 },
|
|
421
|
+
{ width: 200, height: 100 },
|
|
422
|
+
{ width: 350, height: 200 },
|
|
423
|
+
{ width: 600, height: 400 },
|
|
424
|
+
])('ordinary paragraphs of that length fit: $width x $height', ({ width, height }) => {
|
|
425
|
+
const maxLength = subject(multiline({ width, height }))!;
|
|
426
|
+
const maxLines = Math.floor(height / px(8));
|
|
427
|
+
const measure = measurerFor('arial', px(8));
|
|
428
|
+
|
|
429
|
+
for (const paragraph of PARAGRAPHS) {
|
|
430
|
+
const text = paragraph.repeat(20).slice(0, maxLength);
|
|
431
|
+
expect(countWrappedLines(text, width, measure)).toBeLessThanOrEqual(maxLines);
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test('stays within a few percent of what ordinary prose really fits', () => {
|
|
436
|
+
const width = 350;
|
|
437
|
+
const height = 200;
|
|
438
|
+
const maxLength = subject(multiline({ width, height }))!;
|
|
439
|
+
const maxLines = Math.floor(height / px(8));
|
|
440
|
+
const measure = measurerFor('arial', px(8));
|
|
441
|
+
|
|
442
|
+
const fits = (text: string, length: number) => {
|
|
443
|
+
return countWrappedLines(text.slice(0, length), width, measure) <= maxLines;
|
|
444
|
+
};
|
|
445
|
+
const proseCapacity = PARAGRAPHS.map(paragraph => {
|
|
446
|
+
const text = paragraph.repeat(40);
|
|
447
|
+
let length = 0;
|
|
448
|
+
while (fits(text, length + 1)) {
|
|
449
|
+
length += 1;
|
|
450
|
+
}
|
|
451
|
+
return length;
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
expect(maxLength).toBeGreaterThan(0.85 * mean(proseCapacity));
|
|
455
|
+
expect(maxLength).toBeLessThan(1.1 * mean(proseCapacity));
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
describe('regression: capacity was under-reported by ~20%', () => {
|
|
460
|
+
/** The previous sample: one of each letter, both cases, and digit. Capitals and digits are wide. */
|
|
461
|
+
const LEGACY_SAMPLE = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
|
462
|
+
|
|
463
|
+
test('a field the old estimate capped at 16 characters really holds 19-21 of typical text', () => {
|
|
464
|
+
const measure = measurerFor('arial', px(8));
|
|
465
|
+
const legacyGlyphWidth = measure(LEGACY_SAMPLE) / LEGACY_SAMPLE.length;
|
|
466
|
+
const width = Math.round(16.5 * legacyGlyphWidth);
|
|
467
|
+
const legacyMaxLength = Math.floor(width / legacyGlyphWidth);
|
|
468
|
+
const actual = REALISTIC_ENTRIES.map(entry => countCharsThatFit(entry, width, measure));
|
|
469
|
+
|
|
470
|
+
// premise: the old algorithm capped this field at 16...
|
|
471
|
+
expect(legacyMaxLength).toBe(16);
|
|
472
|
+
// ...while 3-5 more characters of ordinary text visibly fit
|
|
473
|
+
expect(mean(actual)).toBeGreaterThanOrEqual(19);
|
|
474
|
+
expect(mean(actual)).toBeLessThanOrEqual(21);
|
|
475
|
+
|
|
476
|
+
const maxLength = subject(buildField({ width }))!;
|
|
477
|
+
|
|
478
|
+
expect(maxLength).toBeGreaterThanOrEqual(legacyMaxLength + 3);
|
|
479
|
+
expect(Math.abs(maxLength - mean(actual))).toBeLessThanOrEqual(1.5);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test('the estimate stays near the real fit across widths and sizes, not only at one point', () => {
|
|
483
|
+
for (const fontSize of [6, 8, 12, 16]) {
|
|
484
|
+
for (const width of [60, 150, 300, 700]) {
|
|
485
|
+
const measure = measurerFor('arial', px(fontSize));
|
|
486
|
+
const actual = mean(
|
|
487
|
+
REALISTIC_ENTRIES.map(entry => countCharsThatFit(entry, width, measure)),
|
|
488
|
+
);
|
|
489
|
+
const maxLength = subject(
|
|
490
|
+
buildField({ width, styles: buildStyles(fontSize) }),
|
|
491
|
+
)!;
|
|
492
|
+
|
|
493
|
+
expect(Math.abs(maxLength - actual)).toBeLessThanOrEqual(
|
|
494
|
+
Math.max(1.5, actual * 0.08),
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
describe('capacity given what is typed', () => {
|
|
502
|
+
const NARROW = 'jrfnrnfjrnfjrnfjrnfjrnfjrnfjrnfjr';
|
|
503
|
+
|
|
504
|
+
test('with nothing typed it is the capacity of the empty field', () => {
|
|
505
|
+
expect(subject(buildField())).toBe(getFieldMaxLength(buildField(), ''));
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
test('narrow text leaves room that a count of 33 would have refused', () => {
|
|
509
|
+
const measure = measurerFor('arial', px(8));
|
|
510
|
+
const field = buildField({ width: 200 });
|
|
511
|
+
const emptyCapacity = subject(field)!;
|
|
512
|
+
|
|
513
|
+
// the reported case: 33 narrow characters fill barely two thirds of the field
|
|
514
|
+
expect(NARROW).toHaveLength(emptyCapacity);
|
|
515
|
+
expect(measure(NARROW)).toBeLessThan(0.75 * 200);
|
|
516
|
+
expect(getFieldMaxLength(field, NARROW)!).toBeGreaterThanOrEqual(emptyCapacity + 8);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
test('wide text leaves less room than narrow text of the same length', () => {
|
|
520
|
+
const field = buildField({ width: 200 });
|
|
521
|
+
|
|
522
|
+
expect(getFieldMaxLength(field, 'i'.repeat(12))!).toBeGreaterThan(
|
|
523
|
+
getFieldMaxLength(field, 'W'.repeat(12))!,
|
|
524
|
+
);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
test('is the characters typed plus the typical characters that still fit', () => {
|
|
528
|
+
const field = buildField({ width: 200 });
|
|
529
|
+
const typed = 'Jonathan Smith';
|
|
530
|
+
const measure = measurerFor('arial', px(8));
|
|
531
|
+
const room = (200 - measure(typed)) / (0.46 * px(8));
|
|
532
|
+
|
|
533
|
+
expect(Math.abs(getFieldMaxLength(field, typed)! - (typed.length + room))).toBeLessThan(
|
|
534
|
+
3,
|
|
535
|
+
);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
test('a value that already overflows has no room left, and is never cut short', () => {
|
|
539
|
+
const overflowing = 'W'.repeat(40);
|
|
540
|
+
|
|
541
|
+
expect(getFieldMaxLength(buildField({ width: 100 }), overflowing)).toBe(40);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
test('shrinks as wider text is typed and grows again when it is deleted', () => {
|
|
545
|
+
const field = buildField({ width: 200 });
|
|
546
|
+
const capacities = ['', 'W', 'WW', 'WWW', 'WW', 'W', ''].map(value => {
|
|
547
|
+
return getFieldMaxLength(field, value)!;
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
expect(capacities[3]).toBeLessThan(capacities[0] + 3);
|
|
551
|
+
expect(capacities[0]).toBe(capacities[6]);
|
|
552
|
+
expect(capacities[1]).toBe(capacities[5]);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
test('a number field counts the typed digits against the room left', () => {
|
|
556
|
+
const field = buildField({ subType: 'number', width: 200 });
|
|
557
|
+
|
|
558
|
+
expect(getFieldMaxLength(field, '12345')).toBe(subject(field));
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
test('multiline: explicit newlines use up lines, leaving less room than plain text', () => {
|
|
562
|
+
const field = buildField({ multiline: true, width: 300, height: 100 });
|
|
563
|
+
const withNewlines = '\n\n\n\n\n';
|
|
564
|
+
const withLetters = 'abcde';
|
|
565
|
+
|
|
566
|
+
expect(getFieldMaxLength(field, withLetters)!).toBeGreaterThan(
|
|
567
|
+
getFieldMaxLength(field, withNewlines)!,
|
|
568
|
+
);
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
test('multiline: a value that needs more lines than fit has no room left', () => {
|
|
572
|
+
const field = buildField({ multiline: true, width: 300, height: 40 });
|
|
573
|
+
const lines = Math.floor(40 / px(8));
|
|
574
|
+
const tooTall = Array.from({ length: lines + 1 }, () => 'x').join('\n');
|
|
575
|
+
|
|
576
|
+
expect(getFieldMaxLength(field, tooTall)).toBe(tooTall.length);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
test('multiline: a value with every line full has almost no room left', () => {
|
|
580
|
+
const field = buildField({ multiline: true, width: 300, height: 40 });
|
|
581
|
+
const lines = Math.floor(40 / px(8));
|
|
582
|
+
// 300px holds 24 W (12.1px each) with under a typical glyph to spare
|
|
583
|
+
const full = Array.from({ length: lines }, () => 'W'.repeat(24)).join('\n');
|
|
584
|
+
|
|
585
|
+
expect(getFieldMaxLength(field, full)! - full.length).toBeLessThanOrEqual(1);
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
test('multiline: room left is what remains after the lines already used', () => {
|
|
589
|
+
const field = buildField({ multiline: true, width: 300, height: 100 });
|
|
590
|
+
const empty = getFieldMaxLength(field)!;
|
|
591
|
+
const oneLineUsed = getFieldMaxLength(field, 'Typed on the first line\n')!;
|
|
592
|
+
const lines = Math.floor(100 / px(8));
|
|
593
|
+
|
|
594
|
+
expect(oneLineUsed).toBeLessThan(empty);
|
|
595
|
+
expect(oneLineUsed).toBeGreaterThan(empty * ((lines - 2) / lines));
|
|
596
|
+
});
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
describe('fields without a free-form text capacity', () => {
|
|
600
|
+
test.each(['checkbox', 'radio', 'date', 'dropdown'] as const)(
|
|
601
|
+
'is undefined for %s',
|
|
602
|
+
subType => {
|
|
603
|
+
expect(subject(buildField({ subType }))).toBeUndefined();
|
|
604
|
+
},
|
|
605
|
+
);
|
|
606
|
+
|
|
607
|
+
test('is undefined for non-fillable field types', () => {
|
|
608
|
+
expect(
|
|
609
|
+
subject(buildField({ type: FieldTypeEnum.generic, subType: 'text' })),
|
|
610
|
+
).toBeUndefined();
|
|
611
|
+
});
|
|
612
|
+
});
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
describe('getFieldMaxLength without canvas support', () => {
|
|
616
|
+
test('falls back to width ratios of the font size', () => {
|
|
617
|
+
const getContext = jest
|
|
618
|
+
.spyOn(HTMLCanvasElement.prototype, 'getContext')
|
|
619
|
+
.mockReturnValue(null);
|
|
620
|
+
|
|
621
|
+
jest.isolateModules(() => {
|
|
622
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
623
|
+
const { getFieldMaxLength: isolated } = require('../get-field-max-length.utils');
|
|
624
|
+
const textLength = isolated(buildField({ width: 200 }));
|
|
625
|
+
const numberLength = isolated(buildField({ subType: 'number', width: 200 }));
|
|
626
|
+
const boldLength = isolated(
|
|
627
|
+
buildField({ width: 200, styles: buildStyles(8, { fontWeight: 'bold' }) }),
|
|
628
|
+
);
|
|
629
|
+
|
|
630
|
+
expect(textLength).toBeGreaterThan(25);
|
|
631
|
+
expect(textLength).toBeLessThan(40);
|
|
632
|
+
expect(numberLength).toBe(Math.floor((200 - SPIN_BUTTON_PX) / (0.556 * px(8))));
|
|
633
|
+
expect(boldLength).toBeLessThan(textLength);
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
getContext.mockRestore();
|
|
637
|
+
});
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
describe('estimateTextCapacity', () => {
|
|
641
|
+
const monospace: TextMeasurer = text => Array.from(text).length * 10;
|
|
642
|
+
|
|
643
|
+
const subject = (
|
|
644
|
+
layout: Parameters<typeof estimateTextCapacity>[0]['layout'],
|
|
645
|
+
overrides: Partial<Parameters<typeof estimateTextCapacity>[0]> = {},
|
|
646
|
+
) => {
|
|
647
|
+
return estimateTextCapacity({
|
|
648
|
+
layout,
|
|
649
|
+
width: 200,
|
|
650
|
+
height: 100,
|
|
651
|
+
lineHeight: 10,
|
|
652
|
+
measure: monospace,
|
|
653
|
+
...overrides,
|
|
654
|
+
});
|
|
655
|
+
};
|
|
656
|
+
|
|
657
|
+
test('single-line is the width divided by the glyph width', () => {
|
|
658
|
+
expect(subject('single-line')).toBe(20);
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
test('number is the width divided by the widest typeable glyph', () => {
|
|
662
|
+
const digitsWiderThanSymbols: TextMeasurer = text => {
|
|
663
|
+
return Array.from(text).reduce((sum, char) => sum + ('.-'.includes(char) ? 4 : 10), 0);
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
expect(subject('number', { measure: digitsWiderThanSymbols })).toBe(20);
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
test('number is bounded by an unusually wide digit', () => {
|
|
670
|
+
const wideEight: TextMeasurer = text => {
|
|
671
|
+
return Array.from(text).reduce((sum, char) => sum + (char === '8' ? 12.5 : 10), 0);
|
|
672
|
+
};
|
|
673
|
+
|
|
674
|
+
expect(subject('number', { measure: wideEight })).toBe(16);
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
test('an exact fit is not lost to floating point error', () => {
|
|
678
|
+
const awkward: TextMeasurer = text => Array.from(text).length * 0.1;
|
|
679
|
+
|
|
680
|
+
expect(subject('number', { width: 2.8, measure: awkward })).toBe(28);
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
test('multiline fits no more than lines x chars per line', () => {
|
|
684
|
+
const capacity = subject('multiline', { width: 100, height: 50, lineHeight: 10 });
|
|
685
|
+
|
|
686
|
+
expect(capacity).toBeLessThanOrEqual(5 * 10);
|
|
687
|
+
expect(capacity).toBeGreaterThan(25);
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
test('multiline reports a single line of room when the height is below one line', () => {
|
|
691
|
+
expect(subject('multiline', { height: 4 })).toBe(subject('multiline', { height: 10 }));
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
test('multiline grows as height adds lines', () => {
|
|
695
|
+
const two = subject('multiline', { height: 20 })!;
|
|
696
|
+
const four = subject('multiline', { height: 40 })!;
|
|
697
|
+
|
|
698
|
+
expect(four).toBeGreaterThan(1.7 * two);
|
|
699
|
+
expect(four).toBeLessThan(2.3 * two);
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
test('the characters already typed count towards the capacity', () => {
|
|
703
|
+
expect(subject('single-line', { value: 'abcde' })).toBe(20);
|
|
704
|
+
expect(subject('single-line', { value: 'a'.repeat(15) })).toBe(20);
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
test('each kind subtracts the width the typed text uses before counting the room left', () => {
|
|
708
|
+
const narrowI: TextMeasurer = text => {
|
|
709
|
+
return Array.from(text).reduce((sum, char) => sum + (char === 'i' ? 2 : 10), 0);
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
// 10 'i' use 20px of 200px: the other 180px hold 18 more 10px glyphs on top of the 10 typed
|
|
713
|
+
expect(subject('single-line', { measure: narrowI, value: 'i'.repeat(10) })).toBe(28);
|
|
714
|
+
expect(subject('number', { measure: narrowI, value: 'i'.repeat(10) })).toBe(28);
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
test('a value that already overflows has no room, and its capacity is its own length', () => {
|
|
718
|
+
const value = 'a'.repeat(30);
|
|
719
|
+
|
|
720
|
+
expect(subject('single-line', { value })).toBe(30);
|
|
721
|
+
expect(subject('number', { value })).toBe(30);
|
|
722
|
+
expect(subject('multiline', { width: 100, height: 20, value: 'a'.repeat(100) })).toBe(100);
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
test('multiline continues after the last typed word instead of gluing text onto it', () => {
|
|
726
|
+
const endsMidWord = subject('multiline', { width: 100, height: 20, value: 'aaaaaaa' })!;
|
|
727
|
+
const endsAfterSpace = subject('multiline', { width: 100, height: 20, value: 'aaaaaaa ' })!;
|
|
728
|
+
|
|
729
|
+
expect(endsMidWord).toBeGreaterThan(7);
|
|
730
|
+
expect(endsAfterSpace).toBeGreaterThan(8);
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
test('always returns at least 1', () => {
|
|
734
|
+
expect(subject('single-line', { width: 1 })).toBe(1);
|
|
735
|
+
expect(subject('number', { width: 1 })).toBe(1);
|
|
736
|
+
expect(subject('multiline', { width: 1, height: 1 })).toBe(1);
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
test('has no limit when the text cannot be measured', () => {
|
|
740
|
+
for (const layout of ['single-line', 'number', 'multiline'] as const) {
|
|
741
|
+
expect(subject(layout, { measure: () => NaN })).toBeUndefined();
|
|
742
|
+
expect(subject(layout, { measure: () => 0 })).toBeUndefined();
|
|
743
|
+
}
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
test('caps absurdly large fields at a sane limit', () => {
|
|
747
|
+
for (const layout of ['single-line', 'number', 'multiline'] as const) {
|
|
748
|
+
expect(subject(layout, { width: 1e6, height: 1e6 })).toBe(10_000);
|
|
749
|
+
}
|
|
750
|
+
});
|
|
751
|
+
});
|