@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.
Files changed (52) hide show
  1. package/README.md +37 -2
  2. package/dist/components/pdf-fields-overlay/pdf-overlay-field-fillable.d.ts.map +1 -1
  3. package/dist/components/pdf-fields-overlay/pdf-overlay-field-fillable.js +4 -6
  4. package/dist/components/pdf-fields-overlay/pdf-overlay-field-fillable.js.map +1 -1
  5. package/dist/components/pdf-view/pdf-view-fillable.d.ts.map +1 -1
  6. package/dist/components/pdf-view/pdf-view-fillable.js +27 -4
  7. package/dist/components/pdf-view/pdf-view-fillable.js.map +1 -1
  8. package/dist/components/shared/char-limit-indicator.d.ts +8 -0
  9. package/dist/components/shared/char-limit-indicator.d.ts.map +1 -0
  10. package/dist/components/shared/char-limit-indicator.js +9 -0
  11. package/dist/components/shared/char-limit-indicator.js.map +1 -0
  12. package/dist/components/shared/index.d.ts +1 -0
  13. package/dist/components/shared/index.d.ts.map +1 -1
  14. package/dist/components/shared/index.js +1 -0
  15. package/dist/components/shared/index.js.map +1 -1
  16. package/dist/styles/pdf-field-overlay.css +9 -2
  17. package/dist/styles/pdf-field.css +22 -1
  18. package/dist/utils/field/field-text-box.utils.d.ts +31 -0
  19. package/dist/utils/field/field-text-box.utils.d.ts.map +1 -0
  20. package/dist/utils/field/field-text-box.utils.js +102 -0
  21. package/dist/utils/field/field-text-box.utils.js.map +1 -0
  22. package/dist/utils/field/fit-field-text.utils.d.ts +13 -0
  23. package/dist/utils/field/fit-field-text.utils.d.ts.map +1 -0
  24. package/dist/utils/field/fit-field-text.utils.js +44 -0
  25. package/dist/utils/field/fit-field-text.utils.js.map +1 -0
  26. package/dist/utils/field/get-field-max-length.utils.d.ts +38 -0
  27. package/dist/utils/field/get-field-max-length.utils.d.ts.map +1 -0
  28. package/dist/utils/field/get-field-max-length.utils.js +166 -0
  29. package/dist/utils/field/get-field-max-length.utils.js.map +1 -0
  30. package/dist/utils/field/index.d.ts +2 -0
  31. package/dist/utils/field/index.d.ts.map +1 -1
  32. package/dist/utils/field/index.js +2 -0
  33. package/dist/utils/field/index.js.map +1 -1
  34. package/dist/utils/field/text-wrap.utils.d.ts +26 -0
  35. package/dist/utils/field/text-wrap.utils.d.ts.map +1 -0
  36. package/dist/utils/field/text-wrap.utils.js +53 -0
  37. package/dist/utils/field/text-wrap.utils.js.map +1 -0
  38. package/package.json +1 -1
  39. package/src/components/pdf-fields-overlay/pdf-overlay-field-fillable.tsx +41 -28
  40. package/src/components/pdf-view/pdf-view-fillable.tsx +82 -36
  41. package/src/components/shared/char-limit-indicator.tsx +23 -0
  42. package/src/components/shared/index.ts +1 -0
  43. package/src/styles/pdf-field-overlay.css +9 -2
  44. package/src/styles/pdf-field.css +22 -1
  45. package/src/utils/field/__tests__/fit-field-text.utils.test.ts +224 -0
  46. package/src/utils/field/__tests__/get-field-max-length.utils.test.ts +751 -0
  47. package/src/utils/field/__tests__/text-wrap.utils.test.ts +181 -0
  48. package/src/utils/field/field-text-box.utils.ts +143 -0
  49. package/src/utils/field/fit-field-text.utils.ts +61 -0
  50. package/src/utils/field/get-field-max-length.utils.ts +211 -0
  51. package/src/utils/field/index.ts +2 -0
  52. package/src/utils/field/text-wrap.utils.ts +78 -0
@@ -0,0 +1,181 @@
1
+ import {
2
+ advanceWrap,
3
+ countWrappedLines,
4
+ INITIAL_WRAP_STATE,
5
+ type TextMeasurer,
6
+ } from '../text-wrap.utils';
7
+
8
+ /** Monospace: every code point is 10px wide, so a 100px box holds exactly 10 characters. */
9
+ const monospace: TextMeasurer = text => Array.from(text).length * 10;
10
+
11
+ describe('countWrappedLines', () => {
12
+ const subject = (text: string, maxWidth = 100, measure: TextMeasurer = monospace) => {
13
+ return countWrappedLines(text, maxWidth, measure);
14
+ };
15
+
16
+ describe('basic wrapping', () => {
17
+ test('an empty value still occupies one line', () => {
18
+ expect(subject('')).toBe(1);
19
+ });
20
+
21
+ test('text narrower than the box stays on one line', () => {
22
+ expect(subject('hello')).toBe(1);
23
+ });
24
+
25
+ test('text exactly as wide as the box does not wrap', () => {
26
+ expect(subject('aaaaaaaaaa')).toBe(1);
27
+ });
28
+
29
+ test('wraps at a word boundary instead of splitting the word', () => {
30
+ // "aaaa bbbb" is 9 chars and fits; " cccc" would end at 14 chars
31
+ expect(subject('aaaa bbbb cccc')).toBe(2);
32
+ });
33
+
34
+ test('wraps once per overflowing word', () => {
35
+ expect(subject('aaaaaaa bbbbbbb ccccccc ddddddd')).toBe(4);
36
+ });
37
+ });
38
+
39
+ describe('spaces', () => {
40
+ test('trailing spaces hang past the edge instead of creating a line', () => {
41
+ expect(subject('aaaaaaaaaa ')).toBe(1);
42
+ });
43
+
44
+ test('spaces that hang push the next word onto the following line', () => {
45
+ expect(subject('aaaaaaaaaa b')).toBe(2);
46
+ });
47
+
48
+ test('consecutive spaces are preserved and count towards the line width', () => {
49
+ expect(subject('a b', 50)).toBe(1);
50
+ expect(subject('a b', 50)).toBe(2);
51
+ });
52
+
53
+ test('leading spaces count towards the first line', () => {
54
+ expect(subject(' aaaaaaaaaa')).toBe(2);
55
+ });
56
+ });
57
+
58
+ describe('explicit newlines', () => {
59
+ test('a newline forces a new line', () => {
60
+ expect(subject('a\nb')).toBe(2);
61
+ });
62
+
63
+ test('a trailing newline opens an empty line, as in a textarea', () => {
64
+ expect(subject('a\n')).toBe(2);
65
+ });
66
+
67
+ test('consecutive newlines each add a line', () => {
68
+ expect(subject('\n\n')).toBe(3);
69
+ });
70
+
71
+ test('treats CRLF and lone CR as a single line break', () => {
72
+ expect(subject('a\r\nb')).toBe(2);
73
+ expect(subject('a\rb')).toBe(2);
74
+ });
75
+
76
+ test('wraps each paragraph independently', () => {
77
+ expect(subject('aaaa bbbb cccc\ndd')).toBe(3);
78
+ });
79
+ });
80
+
81
+ describe('long unbroken words', () => {
82
+ test('breaks a word wider than the box between characters', () => {
83
+ // 25 chars at 10 per line: 10 + 10 + 5
84
+ expect(subject('a'.repeat(25))).toBe(3);
85
+ });
86
+
87
+ test('moves a long word to a fresh line before breaking it', () => {
88
+ // "ab " takes line 1, then the 25-char word fills 10 + 10 + 5
89
+ expect(subject(`ab ${'a'.repeat(25)}`)).toBe(4);
90
+ });
91
+
92
+ test('continues after a broken word on its last partial line', () => {
93
+ // the 25-char word leaves 50px free on its last line, enough for " bbbb"
94
+ expect(subject(`${'a'.repeat(25)} bbbb`)).toBe(3);
95
+ });
96
+
97
+ test('always places at least one character per line, even in a box narrower than a glyph', () => {
98
+ expect(subject('abc', 5)).toBe(3);
99
+ });
100
+ });
101
+
102
+ describe('proportional glyphs', () => {
103
+ const proportional: TextMeasurer = text => {
104
+ return Array.from(text).reduce((width, char) => {
105
+ if (char === 'i') {
106
+ return width + 2;
107
+ }
108
+ if (char === 'W') {
109
+ return width + 14;
110
+ }
111
+ return width + 8;
112
+ }, 0);
113
+ };
114
+
115
+ test('narrow glyphs pack far more characters per line than wide ones', () => {
116
+ expect(subject('i'.repeat(20), 100, proportional)).toBe(1);
117
+ expect(subject('W'.repeat(20), 100, proportional)).toBe(3);
118
+ });
119
+
120
+ test('line count depends on rendered width, not character count', () => {
121
+ const narrow = `${'i'.repeat(20)} ${'i'.repeat(20)} ${'i'.repeat(20)}`;
122
+ const wide = `${'W'.repeat(6)} ${'W'.repeat(6)} ${'W'.repeat(6)}`;
123
+ expect(narrow.length).toBeGreaterThan(wide.length);
124
+ expect(subject(narrow, 100, proportional)).toBeLessThan(
125
+ subject(wide, 100, proportional),
126
+ );
127
+ });
128
+ });
129
+
130
+ describe('unicode', () => {
131
+ test('never splits a surrogate pair when breaking a long word', () => {
132
+ // 25 code points (50 UTF-16 units): 10 + 10 + 5. Splitting units would give 5 lines.
133
+ expect(subject('😀'.repeat(25))).toBe(3);
134
+ });
135
+
136
+ test('breaks unspaced non-Latin text between characters', () => {
137
+ const cjk: TextMeasurer = text => Array.from(text).length * 20;
138
+ const text = '日本語のテキストです。あ';
139
+ // 12 full-width characters, 5 per 100px line: 5 + 5 + 2
140
+ expect(Array.from(text)).toHaveLength(12);
141
+ expect(subject(text, 100, cjk)).toBe(3);
142
+ });
143
+ });
144
+ });
145
+
146
+ describe('advanceWrap', () => {
147
+ const subject = (state: typeof INITIAL_WRAP_STATE, text: string, maxWidth = 100) => {
148
+ return advanceWrap(state, text, maxWidth, monospace);
149
+ };
150
+
151
+ test('starts from an empty box that already occupies one line', () => {
152
+ expect(INITIAL_WRAP_STATE).toEqual({ lines: 1, lineWidth: 0 });
153
+ expect(subject(INITIAL_WRAP_STATE, '')).toEqual({ lines: 1, lineWidth: 0 });
154
+ });
155
+
156
+ test('reports the lines used and how full the last one is', () => {
157
+ expect(subject(INITIAL_WRAP_STATE, 'aaaa bbbb cccc')).toEqual({ lines: 2, lineWidth: 40 });
158
+ });
159
+
160
+ test('resuming from a state gives the same result as laying everything out at once', () => {
161
+ const first = 'aaaa bbbb ';
162
+ const second = 'cccc dddd eeee ffffffffff gggg\nhh ';
163
+
164
+ const resumed = subject(subject(INITIAL_WRAP_STATE, first), second);
165
+
166
+ expect(resumed).toEqual(subject(INITIAL_WRAP_STATE, first + second));
167
+ expect(resumed.lines).toBe(countWrappedLines(first + second, 100, monospace));
168
+ });
169
+
170
+ test('a newline resets the line, whatever came before', () => {
171
+ expect(subject({ lines: 2, lineWidth: 80 }, '\nab')).toEqual({ lines: 3, lineWidth: 20 });
172
+ });
173
+
174
+ test('does not change the state it is given', () => {
175
+ const state = { lines: 1, lineWidth: 30 };
176
+
177
+ subject(state, 'aaaa bbbb cccc dddd');
178
+
179
+ expect(state).toEqual({ lines: 1, lineWidth: 30 });
180
+ });
181
+ });
@@ -0,0 +1,143 @@
1
+ import { type CSSProperties } from 'react';
2
+ import { FieldTypeEnum, type PdfField } from '../../interface/types';
3
+ import { getFieldFontSizeInPixels } from './get-field-font-size.utils';
4
+ import { getFieldTypographyStyle } from './get-field-typography.utils';
5
+ import { advanceWrap, INITIAL_WRAP_STATE, type TextMeasurer } from './text-wrap.utils';
6
+
7
+ export type FieldTextLayout = 'single-line' | 'multiline' | 'number';
8
+
9
+ /** Tolerance for float noise, so an exact fit (e.g. 28 x 7.1px in 198.8px) isn't rejected. */
10
+ export const EPSILON = 1e-6;
11
+
12
+ /**
13
+ * `.dte-pdf-field-fillable` box model (pdf-field-overlay.css): no padding or border, and
14
+ * `line-height: 1`. Update together with the CSS if either changes.
15
+ */
16
+ const FILLABLE_TEXT_INSET_PX = { horizontal: 0, vertical: 0 };
17
+ const FILLABLE_LINE_HEIGHT_RATIO = 1;
18
+
19
+ /**
20
+ * Chrome keeps the inner spin button's 15px gutter free of text in `<input type="number">` even
21
+ * while the button is hidden, so a long value is clipped by this much. Applied in every browser,
22
+ * so one that doesn't reserve it is at worst slightly conservative.
23
+ */
24
+ const NUMBER_INPUT_SPIN_BUTTON_PX = 15;
25
+
26
+ /** Per-glyph width (em) used when canvas measurement isn't available (SSR, jsdom). */
27
+ const FALLBACK_TEXT_WIDTH_EM = 0.46;
28
+ const FALLBACK_DIGIT_WIDTH_EM = 0.556;
29
+ const FALLBACK_BOLD_WIDTH_FACTOR = 1.06;
30
+
31
+ /** What a text-entry field offers to lay text out in, with a way to measure that text. */
32
+ export interface FieldTextBox {
33
+ layout: FieldTextLayout;
34
+ /** Content-box width in px (field width minus horizontal padding and borders). */
35
+ width: number;
36
+ /** Content-box height in px. */
37
+ height: number;
38
+ /** Height of one line box in px. */
39
+ lineHeight: number;
40
+ measure: TextMeasurer;
41
+ /** Same key, same geometry and font: results for it can be shared. */
42
+ key: string;
43
+ }
44
+
45
+ let measureContext: CanvasRenderingContext2D | null | undefined;
46
+
47
+ const getMeasureContext = (): CanvasRenderingContext2D | null => {
48
+ if (measureContext === undefined) {
49
+ measureContext =
50
+ typeof document === 'undefined'
51
+ ? null
52
+ : (document.createElement('canvas').getContext('2d') ?? null);
53
+ }
54
+ return measureContext;
55
+ };
56
+
57
+ interface FieldTypography extends Pick<CSSProperties, 'fontFamily' | 'fontWeight'> {
58
+ fontSizePx: number;
59
+ }
60
+
61
+ const createTextMeasurer = (
62
+ { fontFamily, fontSizePx, fontWeight }: FieldTypography,
63
+ layout: FieldTextLayout,
64
+ ): TextMeasurer => {
65
+ const ctx = getMeasureContext();
66
+
67
+ if (!ctx) {
68
+ const isBold = fontWeight === 'bold';
69
+ const widthEm =
70
+ layout === 'number'
71
+ ? FALLBACK_DIGIT_WIDTH_EM
72
+ : FALLBACK_TEXT_WIDTH_EM * (isBold ? FALLBACK_BOLD_WIDTH_FACTOR : 1);
73
+ return text => text.length * fontSizePx * widthEm;
74
+ }
75
+
76
+ const font = `${fontWeight} ${fontSizePx}px ${fontFamily}`;
77
+ return text => {
78
+ ctx.font = font;
79
+ return ctx.measureText(text).width;
80
+ };
81
+ };
82
+
83
+ const getFieldTextLayout = (field: PdfField): FieldTextLayout | undefined => {
84
+ if (field.type !== FieldTypeEnum.fillable) {
85
+ return undefined;
86
+ }
87
+ if (field.subType === 'number') {
88
+ return 'number';
89
+ }
90
+ if (field.subType === 'text') {
91
+ return field.multiline ? 'multiline' : 'single-line';
92
+ }
93
+ return undefined;
94
+ };
95
+
96
+ /**
97
+ * The measurable text box of a fillable `text`/`number` field, measured with canvas at the
98
+ * field's own font family, weight and size. `undefined` for every other kind of field.
99
+ */
100
+ export const getFieldTextBox = (field: PdfField): FieldTextBox | undefined => {
101
+ const layout = getFieldTextLayout(field);
102
+ if (!layout) {
103
+ return undefined;
104
+ }
105
+
106
+ const { fontFamily, fontWeight } = getFieldTypographyStyle(field);
107
+ const fontSizePx = getFieldFontSizeInPixels(field);
108
+ const spinButtonWidth = layout === 'number' ? NUMBER_INPUT_SPIN_BUTTON_PX : 0;
109
+ const width = field.width - 2 * FILLABLE_TEXT_INSET_PX.horizontal - spinButtonWidth;
110
+ const height = field.height - 2 * FILLABLE_TEXT_INSET_PX.vertical;
111
+
112
+ return {
113
+ layout,
114
+ width,
115
+ height,
116
+ lineHeight: fontSizePx * FILLABLE_LINE_HEIGHT_RATIO,
117
+ measure: createTextMeasurer({ fontFamily, fontSizePx, fontWeight }, layout),
118
+ key: [layout, width, height, fontSizePx, fontWeight, fontFamily].join('|'),
119
+ };
120
+ };
121
+
122
+ /** Whole lines of text that fit the box height; always at least one. */
123
+ export const getMaxLines = ({
124
+ height,
125
+ lineHeight,
126
+ }: Pick<FieldTextBox, 'height' | 'lineHeight'>) => {
127
+ return Math.max(1, Math.floor(height / lineHeight + EPSILON));
128
+ };
129
+
130
+ /**
131
+ * Whether `text` fits the box without overflowing: its width on one line, or its wrapped lines.
132
+ * A single character always counts as fitting, so no field is ever left impossible to type in.
133
+ */
134
+ export const doesTextFitBox = (box: FieldTextBox, text: string): boolean => {
135
+ if (Array.from(text).length <= 1) {
136
+ return true;
137
+ }
138
+ if (box.layout === 'multiline') {
139
+ const { lines } = advanceWrap(INITIAL_WRAP_STATE, text, box.width, box.measure);
140
+ return lines <= getMaxLines(box);
141
+ }
142
+ return box.measure(text) <= box.width + EPSILON;
143
+ };
@@ -0,0 +1,61 @@
1
+ import { type PdfField } from '../../interface/types';
2
+ import { doesTextFitBox, getFieldTextBox } from './field-text-box.utils';
3
+ import { findLargestFit } from './get-field-max-length.utils';
4
+
5
+ /** Splits `next` into what `previous` and `next` share at both ends, and what sits between. */
6
+ const getInsertion = (previous: string, next: string) => {
7
+ const shorter = Math.min(previous.length, next.length);
8
+
9
+ let headLength = 0;
10
+ while (headLength < shorter && previous[headLength] === next[headLength]) {
11
+ headLength += 1;
12
+ }
13
+
14
+ let tailLength = 0;
15
+ while (
16
+ tailLength < shorter - headLength &&
17
+ previous[previous.length - 1 - tailLength] === next[next.length - 1 - tailLength]
18
+ ) {
19
+ tailLength += 1;
20
+ }
21
+
22
+ return {
23
+ head: next.slice(0, headLength),
24
+ inserted: next.slice(headLength, next.length - tailLength),
25
+ tail: next.slice(next.length - tailLength),
26
+ };
27
+ };
28
+
29
+ /**
30
+ * The value a fillable `text`/`number` field should take when its text goes from `previousValue`
31
+ * to `nextValue`: `nextValue` when the text fits the field, otherwise as much of it as fits.
32
+ *
33
+ * Fit is measured from the rendered text (width on one line, wrapped lines in a multiline
34
+ * field), so narrow text keeps filling the field after wide text has stopped. Like the native
35
+ * `maxLength`, it is the text just inserted or pasted that is cut back, never what was already
36
+ * there, and an edit that would only add overflow is refused outright. Deleting is always allowed,
37
+ * even from a value that already overflows.
38
+ */
39
+ export const fitFieldTextChange = (
40
+ field: PdfField,
41
+ previousValue: string,
42
+ nextValue: string,
43
+ ): string => {
44
+ const box = getFieldTextBox(field);
45
+ if (!box || doesTextFitBox(box, nextValue)) {
46
+ return nextValue;
47
+ }
48
+
49
+ const { head, inserted, tail } = getInsertion(previousValue, nextValue);
50
+ const insertedChars = Array.from(inserted);
51
+ const withInserted = (count: number) => {
52
+ return head + insertedChars.slice(0, count).join('') + tail;
53
+ };
54
+
55
+ const keep = findLargestFit(
56
+ count => doesTextFitBox(box, withInserted(count)),
57
+ insertedChars.length,
58
+ );
59
+
60
+ return keep === 0 && insertedChars.length > 0 ? previousValue : withInserted(keep);
61
+ };
@@ -0,0 +1,211 @@
1
+ import { type PdfField } from '../../interface/types';
2
+ import { EPSILON, getFieldTextBox, getMaxLines, type FieldTextBox } from './field-text-box.utils';
3
+ import {
4
+ advanceWrap,
5
+ INITIAL_WRAP_STATE,
6
+ type TextMeasurer,
7
+ type WrapState,
8
+ } from './text-wrap.utils';
9
+
10
+ /**
11
+ * Typical free-text form entries (names, addresses, emails, short notes) in mixed case.
12
+ *
13
+ * Capacity is estimated for this text rather than for a uniform `A-Za-z0-9` alphabet: that one
14
+ * averages ~0.58em per glyph against ~0.46em here (and ~0.47em for real entries), which is why
15
+ * the previous estimate under-reported capacity by ~20%. Contains no hyphens or newlines, so
16
+ * the wrap simulation in `text-wrap.utils` stays exact for it.
17
+ */
18
+ const TEXT_SAMPLE =
19
+ 'Jonathan Alexander Smith 1234 Elm Street, Apartment 5B Springfield, IL 62704 ' +
20
+ 'jonathan.smith@example.com Maria Garcia Lopez Estimate #48213 for $1,250.00 ' +
21
+ 'Replace the capacitor and clean the coils. Call before arriving, gate code 4471. ' +
22
+ 'Dana Whitfield, Park Avenue Plumbing ';
23
+
24
+ /** Glyphs a user can type into a number field; the capacity is bounded by the widest of them. */
25
+ const NUMBER_GLYPHS = '0123456789.-';
26
+
27
+ /** Evenly spaced word-start offsets into the sample that multiline wrapping is averaged over. */
28
+ const MULTILINE_SAMPLE_PHASES = 8;
29
+
30
+ /** Hard stop for the search, so a degenerate measurer (zero-width glyphs) can't loop forever. */
31
+ const MAX_CAPACITY = 10_000;
32
+
33
+ /**
34
+ * Largest `length` in `[0, max]` for which `fits(length)` holds; `fits` must be monotone
35
+ * (once a length stops fitting, longer ones don't either) and `fits(0)` is taken as true.
36
+ */
37
+ export const findLargestFit = (fits: (length: number) => boolean, max = MAX_CAPACITY): number => {
38
+ let low = 0;
39
+ let high = max + 1;
40
+
41
+ for (let probe = 1; probe < high; probe *= 2) {
42
+ if (!fits(probe)) {
43
+ high = probe;
44
+ break;
45
+ }
46
+ low = probe;
47
+ }
48
+
49
+ while (high - low > 1) {
50
+ const mid = Math.floor((low + high) / 2);
51
+ if (fits(mid)) {
52
+ low = mid;
53
+ } else {
54
+ high = mid;
55
+ }
56
+ }
57
+
58
+ return low;
59
+ };
60
+
61
+ /** The sample starting at `start` and wrapping around, repeated to `length` characters. */
62
+ const getSampleText = (start: number, length: number) => {
63
+ const rotated = TEXT_SAMPLE.slice(start) + TEXT_SAMPLE.slice(0, start);
64
+ return rotated.repeat(Math.ceil(length / rotated.length)).slice(0, length);
65
+ };
66
+
67
+ const SAMPLE_WORD_STARTS = Array.from(TEXT_SAMPLE, (char, index) => index).filter(index => {
68
+ return TEXT_SAMPLE[index] !== ' ' && (index === 0 || TEXT_SAMPLE[index - 1] === ' ');
69
+ });
70
+
71
+ const SAMPLE_PHASES = Array.from({ length: MULTILINE_SAMPLE_PHASES }, (_, index) => {
72
+ return SAMPLE_WORD_STARTS[
73
+ Math.floor((index * SAMPLE_WORD_STARTS.length) / MULTILINE_SAMPLE_PHASES)
74
+ ];
75
+ });
76
+
77
+ type TextBoxGeometry = Omit<FieldTextBox, 'key'>;
78
+
79
+ const getRemainingWidth = ({ measure, width }: TextBoxGeometry, value: string) => {
80
+ return Math.max(0, width - measure(value));
81
+ };
82
+
83
+ /**
84
+ * Number inputs only ever hold digits, `.` and `-`, so every value of N characters is at most
85
+ * N widest-glyphs wide: the room left is a guaranteed fit, not an average.
86
+ */
87
+ const estimateNumberRoom = (box: TextBoxGeometry, value: string) => {
88
+ const widestGlyph = Math.max(...Array.from(NUMBER_GLYPHS, glyph => box.measure(glyph)));
89
+ return Math.floor(getRemainingWidth(box, value) / widestGlyph + EPSILON);
90
+ };
91
+
92
+ /** Characters of typical text that fit in the width left: it over the sample's mean glyph. */
93
+ const estimateSingleLineRoom = (box: TextBoxGeometry, value: string) => {
94
+ const meanGlyphWidth = box.measure(TEXT_SAMPLE) / TEXT_SAMPLE.length;
95
+ return Math.floor(getRemainingWidth(box, value) / meanGlyphWidth + EPSILON);
96
+ };
97
+
98
+ /**
99
+ * Characters of typical text that fit in the lines left after `value`. Real wrapping is
100
+ * simulated, so the unused space at the end of each line is accounted for; the result is
101
+ * averaged over several starting points in the sample because it shifts by a few characters
102
+ * with where the text begins.
103
+ */
104
+ const estimateMultilineRoom = (box: TextBoxGeometry, value: string) => {
105
+ const maxLines = getMaxLines(box);
106
+ const measureCache = new Map<string, number>();
107
+ const measure: TextMeasurer = text => {
108
+ let measured = measureCache.get(text);
109
+ if (measured === undefined) {
110
+ measured = box.measure(text);
111
+ measureCache.set(text, measured);
112
+ }
113
+ return measured;
114
+ };
115
+
116
+ const valueState = advanceWrap(INITIAL_WRAP_STATE, value, box.width, measure);
117
+ if (valueState.lines > maxLines) {
118
+ return 0;
119
+ }
120
+
121
+ // typed text goes on after a word, so continue it with a space unless it already ends in one
122
+ const separator = /(^|[ \n])$/.test(value) ? '' : ' ';
123
+ const fitsAfterValue = (state: WrapState, text: string) => {
124
+ return advanceWrap(state, text, box.width, measure).lines <= maxLines;
125
+ };
126
+
127
+ const capacities = SAMPLE_PHASES.map(start => {
128
+ return findLargestFit(length => {
129
+ return fitsAfterValue(valueState, separator + getSampleText(start, length));
130
+ });
131
+ });
132
+
133
+ return Math.floor(capacities.reduce((sum, value) => sum + value, 0) / capacities.length);
134
+ };
135
+
136
+ export type TextCapacityParams = TextBoxGeometry & {
137
+ /** What is already typed; it counts towards the capacity and uses up some of the room. */
138
+ value?: string;
139
+ };
140
+
141
+ /**
142
+ * Highest character count a text box can hold, from rendered text widths: the characters
143
+ * already in `value` plus how many more fit after it.
144
+ *
145
+ * - `number`: guaranteed fit, bounded by the widest of the digits, `.` and `-`
146
+ * - `single-line`: expected fit for typical text, from the mean glyph width of a sample
147
+ * - `multiline`: expected fit for typical text, from simulated word wrapping into the
148
+ * `floor(height / lineHeight)` lines available
149
+ *
150
+ * Narrow text leaves more room than wide text, so the answer moves with what is typed. A
151
+ * value that already overflows has no room left: the capacity is just its own length.
152
+ * At least 1; `undefined` when `measure` yields no usable text width, since there is then
153
+ * no basis for a limit.
154
+ */
155
+ export const estimateTextCapacity = ({
156
+ value = '',
157
+ ...box
158
+ }: TextCapacityParams): number | undefined => {
159
+ const sampleWidth = box.measure(TEXT_SAMPLE);
160
+ if (!Number.isFinite(sampleWidth) || sampleWidth <= 0) {
161
+ return undefined;
162
+ }
163
+
164
+ let room: number;
165
+ switch (box.layout) {
166
+ case 'number':
167
+ room = estimateNumberRoom(box, value);
168
+ break;
169
+ case 'multiline':
170
+ room = estimateMultilineRoom(box, value);
171
+ break;
172
+ default:
173
+ room = estimateSingleLineRoom(box, value);
174
+ }
175
+
176
+ return Number.isNaN(room)
177
+ ? undefined
178
+ : Math.max(1, value.length + Math.min(MAX_CAPACITY, room));
179
+ };
180
+
181
+ const MAX_LENGTH_CACHE_SIZE = 500;
182
+ const maxLengthCache = new Map<string, number | undefined>();
183
+
184
+ /**
185
+ * Max character count a fillable `text`/`number` field can hold given what is typed in it, so
186
+ * the counter can say how much room is left. With no `value` it is the field's capacity when
187
+ * empty. Input is limited by what actually fits, see `fitFieldTextChange`, not by this count.
188
+ *
189
+ * See {@link estimateTextCapacity} for how each field kind is measured. Text is measured
190
+ * with canvas at the field's own font, so it follows family, weight and size.
191
+ */
192
+ export const getFieldMaxLength = (field: PdfField, value = ''): number | undefined => {
193
+ const box = getFieldTextBox(field);
194
+ if (!box) {
195
+ return undefined;
196
+ }
197
+
198
+ const cacheKey = `${box.key}|${value}`;
199
+ if (maxLengthCache.has(cacheKey)) {
200
+ return maxLengthCache.get(cacheKey);
201
+ }
202
+
203
+ const maxLength = estimateTextCapacity({ ...box, value });
204
+
205
+ if (maxLengthCache.size >= MAX_LENGTH_CACHE_SIZE) {
206
+ maxLengthCache.clear();
207
+ }
208
+ maxLengthCache.set(cacheKey, maxLength);
209
+
210
+ return maxLength;
211
+ };
@@ -5,8 +5,10 @@ export * from './field-drag.utils';
5
5
  export * from './field-placeholder-text.utils';
6
6
  export * from './field-reading-order.utils';
7
7
  export * from './field-resize.utils';
8
+ export * from './fit-field-text.utils';
8
9
  export * from './get-field-bounds-style.utils';
9
10
  export * from './get-field-font-size.utils';
11
+ export * from './get-field-max-length.utils';
10
12
  export * from './get-field-text-clip.utils';
11
13
  export * from './get-field-type-option.utils';
12
14
  export * from './get-field-typography.utils';
@@ -0,0 +1,78 @@
1
+ /** Returns the rendered width of `text` in pixels for one fixed font. */
2
+ export type TextMeasurer = (text: string) => number;
3
+
4
+ /** Where text wrapping has got to: the line being filled, and how wide it is so far. */
5
+ export interface WrapState {
6
+ lines: number;
7
+ lineWidth: number;
8
+ }
9
+
10
+ /** An empty box still occupies one line. */
11
+ export const INITIAL_WRAP_STATE: WrapState = { lines: 1, lineWidth: 0 };
12
+
13
+ /**
14
+ * Continues laying out `text` after `state` in a box `maxWidth` wide, simulating browser layout
15
+ * for `white-space: pre-wrap; overflow-wrap: break-word` (the multiline fillable textarea):
16
+ *
17
+ * - `\n` forces a line break; a trailing `\n` still opens a line, as in a textarea
18
+ * - words wrap at spaces; runs of spaces are preserved and hang past the edge instead of wrapping
19
+ * - a word wider than a whole line is broken between characters (code points)
20
+ *
21
+ * `text` must start at a word boundary of the text already laid out, i.e. not continue a word.
22
+ *
23
+ * Not modelled: break opportunities after hyphens and other punctuation, which only ever let
24
+ * browsers pack lines more tightly, so the line count is never optimistic.
25
+ */
26
+ export const advanceWrap = (
27
+ state: WrapState,
28
+ text: string,
29
+ maxWidth: number,
30
+ measure: TextMeasurer,
31
+ ): WrapState => {
32
+ let { lineWidth, lines } = state;
33
+
34
+ for (const token of text.replace(/\r\n?/g, '\n').match(/\n| +|[^ \n]+/g) ?? []) {
35
+ if (token === '\n') {
36
+ lines += 1;
37
+ lineWidth = 0;
38
+ continue;
39
+ }
40
+
41
+ const tokenWidth = measure(token);
42
+
43
+ if (token[0] === ' ' || lineWidth + tokenWidth <= maxWidth) {
44
+ lineWidth += tokenWidth;
45
+ continue;
46
+ }
47
+
48
+ if (lineWidth > 0) {
49
+ lines += 1;
50
+ lineWidth = 0;
51
+ }
52
+
53
+ if (tokenWidth <= maxWidth) {
54
+ lineWidth = tokenWidth;
55
+ continue;
56
+ }
57
+
58
+ for (const char of token) {
59
+ const charWidth = measure(char);
60
+ if (lineWidth > 0 && lineWidth + charWidth > maxWidth) {
61
+ lines += 1;
62
+ lineWidth = 0;
63
+ }
64
+ lineWidth += charWidth;
65
+ }
66
+ }
67
+
68
+ return { lines, lineWidth };
69
+ };
70
+
71
+ /** Counts the lines `text` occupies in a box `maxWidth` wide; see {@link advanceWrap}. */
72
+ export const countWrappedLines = (
73
+ text: string,
74
+ maxWidth: number,
75
+ measure: TextMeasurer,
76
+ ): number => {
77
+ return advanceWrap(INITIAL_WRAP_STATE, text, maxWidth, measure).lines;
78
+ };