@jasy/pdf 1.0.0-alpha.7 → 1.0.0-alpha.9

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 (140) hide show
  1. package/README.md +11 -10
  2. package/dist/api/content.d.ts +5 -4
  3. package/dist/api/content.js +13 -7
  4. package/dist/api/descriptor.js +23 -0
  5. package/dist/api/dimension.d.ts +56 -0
  6. package/dist/api/dimension.js +27 -0
  7. package/dist/api/font-url.d.ts +28 -0
  8. package/dist/api/font-url.js +134 -0
  9. package/dist/api/forms.d.ts +347 -0
  10. package/dist/api/forms.js +189 -0
  11. package/dist/api/gradient.d.ts +53 -0
  12. package/dist/api/gradient.js +97 -0
  13. package/dist/api/index.d.ts +3 -0
  14. package/dist/api/index.js +3 -0
  15. package/dist/api/insets.d.ts +19 -12
  16. package/dist/api/insets.js +18 -7
  17. package/dist/api/layout.d.ts +20 -6
  18. package/dist/api/layout.js +31 -6
  19. package/dist/api/page-builder.d.ts +8 -0
  20. package/dist/api/page-builder.js +12 -0
  21. package/dist/api/structure.d.ts +11 -0
  22. package/dist/api/structure.js +17 -4
  23. package/dist/api/text.d.ts +42 -6
  24. package/dist/api/text.js +29 -5
  25. package/dist/crypto/legacy-handler.d.ts +43 -0
  26. package/dist/crypto/legacy-handler.js +145 -0
  27. package/dist/crypto/security-handler.d.ts +16 -0
  28. package/dist/crypto/security-handler.js +26 -0
  29. package/dist/edit/acroform-reader.d.ts +70 -0
  30. package/dist/edit/acroform-reader.js +126 -0
  31. package/dist/edit/appearance.d.ts +47 -0
  32. package/dist/edit/appearance.js +171 -0
  33. package/dist/edit/document.d.ts +145 -0
  34. package/dist/edit/document.js +642 -0
  35. package/dist/edit/fill.d.ts +65 -0
  36. package/dist/edit/fill.js +424 -0
  37. package/dist/edit/flatten.d.ts +54 -0
  38. package/dist/edit/flatten.js +462 -0
  39. package/dist/edit/index.d.ts +20 -0
  40. package/dist/edit/index.js +18 -0
  41. package/dist/edit/lexer.d.ts +30 -0
  42. package/dist/edit/lexer.js +285 -0
  43. package/dist/edit/objects.d.ts +68 -0
  44. package/dist/edit/objects.js +83 -0
  45. package/dist/edit/writer.d.ts +60 -0
  46. package/dist/edit/writer.js +283 -0
  47. package/dist/elements/container-element.d.ts +21 -1
  48. package/dist/elements/container-element.js +25 -10
  49. package/dist/elements/forms/checkbox-element.d.ts +51 -0
  50. package/dist/elements/forms/checkbox-element.js +48 -0
  51. package/dist/elements/forms/choice-element.d.ts +53 -0
  52. package/dist/elements/forms/choice-element.js +59 -0
  53. package/dist/elements/forms/push-button-element.d.ts +56 -0
  54. package/dist/elements/forms/push-button-element.js +49 -0
  55. package/dist/elements/forms/radio-element.d.ts +52 -0
  56. package/dist/elements/forms/radio-element.js +49 -0
  57. package/dist/elements/forms/signature-element.d.ts +54 -0
  58. package/dist/elements/forms/signature-element.js +47 -0
  59. package/dist/elements/forms/text-field-element.d.ts +61 -0
  60. package/dist/elements/forms/text-field-element.js +54 -0
  61. package/dist/elements/image-element.d.ts +18 -4
  62. package/dist/elements/image-element.js +27 -12
  63. package/dist/elements/index.d.ts +6 -0
  64. package/dist/elements/index.js +6 -0
  65. package/dist/elements/layout/expanded-element.d.ts +3 -1
  66. package/dist/elements/layout/expanded-element.js +12 -3
  67. package/dist/elements/layout/padding-element.d.ts +4 -2
  68. package/dist/elements/layout/padding-element.js +4 -2
  69. package/dist/elements/layout/page-builder-element.js +2 -0
  70. package/dist/elements/pdf-element.d.ts +59 -1
  71. package/dist/elements/pdf-element.js +62 -1
  72. package/dist/elements/rectangle-element.d.ts +20 -6
  73. package/dist/elements/rectangle-element.js +25 -12
  74. package/dist/elements/row-element.d.ts +24 -1
  75. package/dist/elements/row-element.js +25 -10
  76. package/dist/elements/text-element.d.ts +60 -1
  77. package/dist/elements/text-element.js +132 -33
  78. package/dist/forms/acroform.d.ts +63 -0
  79. package/dist/forms/acroform.js +427 -0
  80. package/dist/forms/appearance.d.ts +58 -0
  81. package/dist/forms/appearance.js +204 -0
  82. package/dist/forms/field.d.ts +164 -0
  83. package/dist/forms/field.js +1 -0
  84. package/dist/forms/pdf.d.ts +19 -0
  85. package/dist/forms/pdf.js +28 -0
  86. package/dist/ir/display-list.d.ts +40 -7
  87. package/dist/ir/display-list.js +8 -1
  88. package/dist/layout/alignment.d.ts +10 -0
  89. package/dist/layout/alignment.js +6 -0
  90. package/dist/layout/box-constraints.d.ts +53 -0
  91. package/dist/layout/box-constraints.js +72 -0
  92. package/dist/layout/insets.d.ts +28 -0
  93. package/dist/layout/insets.js +20 -0
  94. package/dist/renderer/form-field-renderer.d.ts +20 -0
  95. package/dist/renderer/form-field-renderer.js +12 -0
  96. package/dist/renderer/image-renderer.js +2 -1
  97. package/dist/renderer/page-renderer.js +8 -2
  98. package/dist/renderer/pdf-backend.d.ts +6 -0
  99. package/dist/renderer/pdf-backend.js +86 -31
  100. package/dist/renderer/pdf-document-renderer.js +8 -1
  101. package/dist/renderer/pdf-renderer.js +19 -1
  102. package/dist/renderer/rectangle-renderer.js +8 -2
  103. package/dist/renderer/text-renderer.d.ts +2 -2
  104. package/dist/renderer/text-renderer.js +150 -42
  105. package/dist/text/advance.js +5 -2
  106. package/dist/text/arabic.d.ts +20 -0
  107. package/dist/text/arabic.js +101 -0
  108. package/dist/text/bidi.d.ts +33 -0
  109. package/dist/text/bidi.js +94 -0
  110. package/dist/text/font-fallback.d.ts +25 -0
  111. package/dist/text/font-fallback.js +38 -0
  112. package/dist/text/line-breaker.d.ts +35 -1
  113. package/dist/text/line-breaker.js +76 -16
  114. package/dist/text/orphans-widows.d.ts +27 -0
  115. package/dist/text/orphans-widows.js +53 -0
  116. package/dist/text/shape.d.ts +29 -0
  117. package/dist/text/shape.js +67 -0
  118. package/dist/text/text-style.d.ts +25 -0
  119. package/dist/text/text-style.js +44 -1
  120. package/dist/utils/dest-registry.d.ts +5 -2
  121. package/dist/utils/dest-registry.js +5 -5
  122. package/dist/utils/flex-layout.d.ts +24 -4
  123. package/dist/utils/flex-layout.js +169 -7
  124. package/dist/utils/font-metrics.d.ts +5 -0
  125. package/dist/utils/gsub.d.ts +36 -0
  126. package/dist/utils/gsub.js +178 -0
  127. package/dist/utils/inflate.d.ts +16 -0
  128. package/dist/utils/inflate.js +52 -0
  129. package/dist/utils/outline.js +1 -2
  130. package/dist/utils/pdf-object-manager.d.ts +39 -0
  131. package/dist/utils/pdf-object-manager.js +166 -15
  132. package/dist/utils/struct-tree.js +2 -3
  133. package/dist/utils/ttf-parser.d.ts +6 -0
  134. package/dist/utils/ttf-parser.js +19 -0
  135. package/dist/utils/utf8-to-windows1252-encoder.d.ts +6 -0
  136. package/dist/utils/utf8-to-windows1252-encoder.js +10 -0
  137. package/dist/utils/woff.d.ts +10 -0
  138. package/dist/utils/woff.js +116 -0
  139. package/dist/validators/element-validator.js +14 -3
  140. package/package.json +10 -2
@@ -0,0 +1,427 @@
1
+ import { NORMAL_STYLE, escName, num2, pdfColor } from "./pdf.js";
2
+ import { checkboxOff, checkboxOn, listBoxFace, pushButtonFace, radioOff, radioOn, signatureFace, fieldDrawSize, textFieldFace, wrapFieldValue, } from "./appearance.js";
3
+ // AcroForm field flags (/Ff), by 1-based bit position per the PDF spec.
4
+ const FF_READ_ONLY = 1 << 0; // bit 1
5
+ const FF_REQUIRED = 1 << 1; // bit 2: must be filled in before the form is submitted
6
+ const FF_MULTILINE = 1 << 12; // bit 13 (text)
7
+ const FF_PASSWORD = 1 << 13; // bit 14 (text)
8
+ const FF_NO_TOGGLE_OFF = 1 << 14; // bit 15 (button): clicking the selected radio does not clear it
9
+ const FF_RADIO = 1 << 15; // bit 16 (button): the group's kids are mutually exclusive
10
+ const FF_PUSHBUTTON = 1 << 16; // bit 17 (button): a click target with no value
11
+ const FF_COMBO = 1 << 17; // bit 18 (choice): a dropdown, not a list box
12
+ const FF_EDIT = 1 << 18; // bit 19 (choice): a combo whose value may be typed, not only picked
13
+ const FF_MULTI_SELECT = 1 << 21; // bit 22 (choice): a list box that allows several selections
14
+ /** The `/MK` (appearance characteristics) + `/BS` (border style) shared by every widget: the box border
15
+ * colour + fill + width. `extraMK` adds field-specific entries (a push button's `/CA` caption) to the
16
+ * SAME /MK dict. Empty when the field has neither a border, a background nor an extra entry. */
17
+ function boxChrome(node, extraMK = "") {
18
+ const { style } = node;
19
+ const mk = [];
20
+ if (extraMK)
21
+ mk.push(extraMK);
22
+ if (style.border)
23
+ mk.push(`/BC [${pdfColor(style.border)}]`);
24
+ if (style.background)
25
+ mk.push(`/BG [${pdfColor(style.background)}]`);
26
+ let out = mk.length ? ` /MK << ${mk.join(" ")} >>` : "";
27
+ if (style.border && style.borderWidth > 0)
28
+ out += ` /BS << /W ${num2(style.borderWidth)} /S /S >>`;
29
+ return out;
30
+ }
31
+ /** The annotation flags (/F). Bit 2 = Hidden, bit 3 = Print. A widget prints by default; `hidden`
32
+ * removes it from screen AND print, `print: false` keeps it on screen only. */
33
+ function annotFlags(f) {
34
+ if (f.hidden)
35
+ return 2;
36
+ return f.print === false ? 0 : 4;
37
+ }
38
+ /** The field flags every kind shares. */
39
+ function commonFlags(f) {
40
+ return (f.readOnly ? FF_READ_ONLY : 0) | (f.required ? FF_REQUIRED : 0);
41
+ }
42
+ /** Quadding (/Q): 0 left (the default, so we omit it), 1 centre, 2 right. */
43
+ function quadding(align) {
44
+ if (align === "center")
45
+ return " /Q 1";
46
+ if (align === "right")
47
+ return " /Q 2";
48
+ return "";
49
+ }
50
+ function rectOf(node) {
51
+ return `[${num2(node.x)} ${num2(node.y)} ${num2(node.x + node.width)} ${num2(node.y + node.height)}]`;
52
+ }
53
+ /** A text field (/Tx). `apRef` is its baked appearance (the default); without one the field carries only
54
+ * /DA and the viewer draws the value itself (`fieldAppearances: false`). */
55
+ function buildTextWidget(node, daFont, om, apRef) {
56
+ const f = node.field;
57
+ if (f.kind !== "text")
58
+ throw new Error("buildTextWidget: not a text field");
59
+ const parts = [`/Type /Annot /Subtype /Widget /FT /Tx`, `/T ${om.pdfString(f.name)}`];
60
+ if (f.value !== undefined)
61
+ parts.push(`/V ${om.pdfString(f.value)}`);
62
+ if (f.tooltip !== undefined)
63
+ parts.push(`/TU ${om.pdfString(f.tooltip)}`);
64
+ let flags = commonFlags(f);
65
+ if (f.multiline)
66
+ flags |= FF_MULTILINE;
67
+ if (f.password)
68
+ flags |= FF_PASSWORD;
69
+ if (flags)
70
+ parts.push(`/Ff ${flags}`);
71
+ if (f.maxLength !== undefined)
72
+ parts.push(`/MaxLen ${f.maxLength}`);
73
+ parts.push(`/Rect ${rectOf(node)} /F ${annotFlags(f)}${quadding(f.align)}`);
74
+ parts.push(`/DA ${om.pdfString(`/${daFont} ${num2(node.style.fontSize)} Tf ${pdfColor(node.style.color)} rg`)}`);
75
+ if (apRef !== undefined)
76
+ parts.push(`/AP << /N ${apRef} 0 R >>`);
77
+ return `<< ${parts.join(" ")}${boxChrome(node)} >>`;
78
+ }
79
+ /** A checkbox (/Btn). Bakes its own /AP appearance streams for the on + off states, so the check is
80
+ * visible everywhere (print / headless / PDF-A), not only in viewers that honour /NeedAppearances. */
81
+ function buildCheckboxWidget(node, om) {
82
+ const f = node.field;
83
+ if (f.kind !== "checkbox")
84
+ throw new Error("buildCheckboxWidget: not a checkbox");
85
+ // Export values become Name tokens, so they are #XX-escaped, not string-escaped.
86
+ const on = escName(f.onValue ?? "Yes");
87
+ const state = f.checked ? on : "Off";
88
+ const bbox = `[0 0 ${num2(node.width)} ${num2(node.height)}]`;
89
+ // One Form XObject per state; the widget's /AS picks which one shows.
90
+ const onRef = om.addFormXObject(bbox, checkboxOn(node.width, node.height, node.style));
91
+ const offRef = om.addFormXObject(bbox, checkboxOff(node.width, node.height, node.style));
92
+ const parts = [`/Type /Annot /Subtype /Widget /FT /Btn`, `/T ${om.pdfString(f.name)}`];
93
+ if (f.tooltip !== undefined)
94
+ parts.push(`/TU ${om.pdfString(f.tooltip)}`);
95
+ if (commonFlags(f))
96
+ parts.push(`/Ff ${commonFlags(f)}`);
97
+ parts.push(`/V /${state} /AS /${state}`);
98
+ parts.push(`/Rect ${rectOf(node)} /F ${annotFlags(f)}`);
99
+ parts.push(`/AP << /N << /${on} ${onRef} 0 R /Off ${offRef} 0 R >> >>`);
100
+ // The check mark lives in ZapfDingbats. We bake the appearance ourselves, so this /DA is only read by
101
+ // a viewer that REGENERATES (which /NeedAppearances asks for) - but then it needs it, and it needs the
102
+ // matching /DR entry, or it silently draws an empty box.
103
+ parts.push(`/DA ${om.pdfString(`/ZaDb 0 Tf ${pdfColor(node.style.color)} rg`)}`);
104
+ return `<< ${parts.join(" ")}${boxChrome(node)} >>`;
105
+ }
106
+ /** What a choice field has selected. A multi-select box uses `values`; a single-select one uses `value`
107
+ * but tolerates a one-entry `values` - the two props are easy to confuse, and silently dropping the
108
+ * user's selection is worse than accepting either spelling. */
109
+ function choiceSelection(f) {
110
+ if (f.multiSelect)
111
+ return f.values ?? (f.value !== undefined ? [f.value] : []);
112
+ if (f.value !== undefined)
113
+ return [f.value];
114
+ return f.values?.length ? [f.values[0]] : [];
115
+ }
116
+ /** A choice field (/Ch): a dropdown (combo) or list box. `apRef` is its baked appearance (the default);
117
+ * without one the viewer draws the selected value from /DA + /V. */
118
+ function buildChoiceWidget(node, daFont, om, apRef) {
119
+ const f = node.field;
120
+ if (f.kind !== "choice")
121
+ throw new Error("buildChoiceWidget: not a choice");
122
+ // /Opt: each entry is [ (export) (display) ], so a label can differ from the stored value.
123
+ const opt = f.options
124
+ .map((o) => `[${om.pdfString(o.value)} ${om.pdfString(o.label ?? o.value)}]`)
125
+ .join(" ");
126
+ const parts = [
127
+ `/Type /Annot /Subtype /Widget /FT /Ch`,
128
+ `/T ${om.pdfString(f.name)}`,
129
+ `/Opt [${opt}]`,
130
+ ];
131
+ if (f.tooltip !== undefined)
132
+ parts.push(`/TU ${om.pdfString(f.tooltip)}`);
133
+ let flags = commonFlags(f);
134
+ if (f.combo)
135
+ flags |= FF_COMBO | (f.editable ? FF_EDIT : 0);
136
+ else if (f.multiSelect)
137
+ flags |= FF_MULTI_SELECT;
138
+ if (flags)
139
+ parts.push(`/Ff ${flags}`);
140
+ const indexOf = (v) => f.options.findIndex((o) => o.value === v);
141
+ const chosen = choiceSelection(f);
142
+ if (chosen.length > 0) {
143
+ // A multi-select field's value is an ARRAY of strings; every other choice field holds one string.
144
+ parts.push(f.multiSelect
145
+ ? `/V [${chosen.map((v) => `${om.pdfString(v)}`).join(" ")}]`
146
+ : `/V ${om.pdfString(chosen[0])}`);
147
+ const idx = chosen.map(indexOf).filter((i) => i >= 0);
148
+ if (idx.length)
149
+ parts.push(`/I [${idx.join(" ")}]`);
150
+ }
151
+ parts.push(`/Rect ${rectOf(node)} /F ${annotFlags(f)}${quadding(f.align)}`);
152
+ parts.push(`/DA ${om.pdfString(`/${daFont} ${num2(node.style.fontSize)} Tf ${pdfColor(node.style.color)} rg`)}`);
153
+ if (apRef !== undefined)
154
+ parts.push(`/AP << /N ${apRef} 0 R >>`);
155
+ return `<< ${parts.join(" ")}${boxChrome(node)} >>`;
156
+ }
157
+ /** The `/A` action a push button fires. Scripted actions are deliberately not offered. */
158
+ function actionDict(a, om) {
159
+ switch (a.kind) {
160
+ case "reset":
161
+ return `/A << /S /ResetForm >>`;
162
+ case "submit":
163
+ // /Flags 4 = ExportFormat: post the field values as HTML form data rather than FDF, which is what
164
+ // an ordinary web endpoint expects.
165
+ return `/A << /S /SubmitForm /F << /FS /URL /F ${om.pdfString(a.url)} >> /Flags 4 >>`;
166
+ case "url":
167
+ return `/A << /S /URI /URI ${om.pdfString(a.url)} >>`;
168
+ }
169
+ }
170
+ /**
171
+ * Collects the document's form fields as pages render, then emits the catalog `/AcroForm` dictionary at
172
+ * finalize. Mirrors `OutlineBuilder` / `DestRegistry`: a no-op returning "" when no field was placed, so
173
+ * a document without a form stays byte-identical.
174
+ */
175
+ export class AcroFormCollector {
176
+ constructor() {
177
+ this.fieldRefs = [];
178
+ this.radioGroups = new Map();
179
+ // Only set when appearance baking is OFF - then the viewer has to draw every value. With baking on
180
+ // (the default) it stays absent, which is also what PDF/A requires.
181
+ this.needAppearances = false;
182
+ // Set once a signature field exists; the catalog then needs /SigFlags.
183
+ this.hasSignature = false;
184
+ // Field name -> its kind, so a collision can say WHAT collided.
185
+ this.usedNames = new Map();
186
+ // Bake every field's appearance (default). Off = emit no /AP and set /NeedAppearances, i.e. let the
187
+ // viewer draw everything - what react-pdf/pdfkit always does, and what we did before this step.
188
+ this.bake = true;
189
+ }
190
+ /** Turn appearance baking off (`renderToBytes(doc, { fieldAppearances: false })`). */
191
+ setBakeAppearances(on) {
192
+ this.bake = on;
193
+ }
194
+ /** The size to draw a value at. A field may ask for `0` - the PDF convention for "auto-size"; we then
195
+ * fit the capitals comfortably inside the box, which is what a viewer's auto-size does. */
196
+ drawSize(style, height, capHeight) {
197
+ return fieldDrawSize(style.fontSize, height, style.borderWidth, capHeight);
198
+ }
199
+ /** Measure one line for the appearance generator. */
200
+ line(om, text, size) {
201
+ return { text, width: om.getStringWidth(text, "Helvetica", size, NORMAL_STYLE) };
202
+ }
203
+ /** Bake a text field's value into an appearance stream; returns the XObject number. */
204
+ bakeText(node, om) {
205
+ const f = node.field;
206
+ if (f.kind !== "text")
207
+ throw new Error("bakeText: not a text field");
208
+ const { capHeight } = om.getFontDecoration("Helvetica", NORMAL_STYLE);
209
+ const size = this.drawSize(node.style, node.height, capHeight);
210
+ // A password field shows the mask, never the characters - the value still lives in /V.
211
+ const value = f.password ? "•".repeat([...(f.value ?? "")].length) : (f.value ?? "");
212
+ const innerWidth = Math.max(1, node.width - 2 * (2 + node.style.borderWidth));
213
+ let lines = [];
214
+ if (value) {
215
+ const texts = wrapFieldValue(value, "Helvetica", size, NORMAL_STYLE, innerWidth, om, f.multiline ?? false);
216
+ lines = texts.map((t) => this.line(om, t, size));
217
+ }
218
+ const face = textFieldFace(node.width, node.height, node.style, lines, capHeight, size, "Helv", f.multiline ?? false, f.align);
219
+ return om.addFormXObject(`[0 0 ${num2(node.width)} ${num2(node.height)}]`, face, `/Font << /Helv ${this.helv(om)} 0 R >>`);
220
+ }
221
+ /** Bake a choice field: a combo shows its current value on one line, a list box shows its options
222
+ * with the selected rows highlighted. */
223
+ bakeChoice(node, om) {
224
+ const f = node.field;
225
+ if (f.kind !== "choice")
226
+ throw new Error("bakeChoice: not a choice");
227
+ const { capHeight } = om.getFontDecoration("Helvetica", NORMAL_STYLE);
228
+ const size = this.drawSize(node.style, node.height, capHeight);
229
+ const shown = (v) => f.options.find((o) => o.value === v)?.label ?? v;
230
+ const chosen = choiceSelection(f);
231
+ const selected = new Set(chosen);
232
+ let face;
233
+ if (f.combo) {
234
+ const current = chosen.length ? [this.line(om, shown(chosen[0]), size)] : [];
235
+ face = textFieldFace(node.width, node.height, node.style, current, capHeight, size, "Helv", false, f.align);
236
+ }
237
+ else {
238
+ const rows = f.options.map((o) => ({
239
+ ...this.line(om, o.label ?? o.value, size),
240
+ selected: selected.has(o.value),
241
+ }));
242
+ face = listBoxFace(node.width, node.height, node.style, rows, capHeight, size, "Helv", f.align);
243
+ }
244
+ return om.addFormXObject(`[0 0 ${num2(node.width)} ${num2(node.height)}]`, face, `/Font << /Helv ${this.helv(om)} 0 R >>`);
245
+ }
246
+ get isEmpty() {
247
+ return this.fieldRefs.length === 0;
248
+ }
249
+ helv(om) {
250
+ return (this.helvNum ?? (this.helvNum = om.addObject(`<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>`)));
251
+ }
252
+ /**
253
+ * ZapfDingbats, the font a check mark lives in. Needed even though we bake the appearance ourselves:
254
+ * a viewer told to regenerate (`/NeedAppearances`, which filling a form sets) redraws a check box from
255
+ * its `/DA`, and the conventional `/DA` for one names `/ZaDb`. Without the matching `/DR` entry the
256
+ * viewer cannot resolve the font - poppler says "Unknown font tag 'ZaDb'" and draws no check at all.
257
+ * No encoding: ZapfDingbats brings its own.
258
+ */
259
+ zadb(om) {
260
+ return (this.zadbNum ?? (this.zadbNum = om.addObject(`<< /Type /Font /Subtype /Type1 /BaseFont /ZapfDingbats >>`)));
261
+ }
262
+ /**
263
+ * A field name is its identity: two fields sharing one are ONE field to every viewer, showing the same
264
+ * value in both places and overwriting each other as the reader types. The format allows it and says
265
+ * nothing, so the mistake surfaces only when someone fills the form. Named here instead.
266
+ *
267
+ * A radio GROUP is the exception - its buttons deliberately share a name, which is what makes them
268
+ * mutually exclusive - so it never comes through here.
269
+ */
270
+ claimName(name, kind) {
271
+ const taken = this.usedNames.get(name);
272
+ if (taken !== undefined) {
273
+ throw new Error(`@jasy/pdf: two form fields are called "${name}" (a ${taken} and a ${kind}). A name is a field's ` +
274
+ "identity - a viewer would treat them as one field and show the same value in both places. " +
275
+ "Give them different names, or use a RadioGroup if they are meant to belong together.");
276
+ }
277
+ this.usedNames.set(name, kind);
278
+ }
279
+ /** Emit the widget object for one form-field IR node, register it as a field, and return its object
280
+ * number so the PageRenderer can add it to that page's /Annots. */
281
+ addField(node, om) {
282
+ if (node.field.kind === "radio")
283
+ return this.addRadio(node, om);
284
+ this.claimName(node.field.name, node.field.kind);
285
+ let dict;
286
+ if (node.field.kind === "checkbox") {
287
+ this.zadb(om); // its /DA names /ZaDb, so /DR has to carry it
288
+ dict = buildCheckboxWidget(node, om);
289
+ }
290
+ else if (node.field.kind === "pushbutton") {
291
+ dict = this.buildPushButtonWidget(node, om);
292
+ }
293
+ else if (node.field.kind === "signature") {
294
+ dict = this.buildSignatureWidget(node, om);
295
+ }
296
+ else if (node.field.kind === "choice") {
297
+ this.helv(om);
298
+ dict = buildChoiceWidget(node, "Helv", om, this.bake ? this.bakeChoice(node, om) : undefined);
299
+ // Only text and choice depend on the flag; the other kinds always carry a complete appearance,
300
+ // so a document of checkboxes alone must not ask the viewer to redraw anything.
301
+ if (!this.bake)
302
+ this.needAppearances = true;
303
+ }
304
+ else {
305
+ this.helv(om);
306
+ dict = buildTextWidget(node, "Helv", om, this.bake ? this.bakeText(node, om) : undefined);
307
+ if (!this.bake)
308
+ this.needAppearances = true;
309
+ }
310
+ const objNum = om.addObject(dict);
311
+ this.fieldRefs.push(objNum);
312
+ return objNum;
313
+ }
314
+ /** A push button (/Btn, Pushbutton flag): no value, an optional action, and a baked caption. The
315
+ * caption is measured here (the appearance module stays free of font metrics) and drawn into an
316
+ * appearance stream that carries its own font resource. */
317
+ buildPushButtonWidget(node, om) {
318
+ const f = node.field;
319
+ if (f.kind !== "pushbutton")
320
+ throw new Error("buildPushButtonWidget: not a push button");
321
+ const { capHeight } = om.getFontDecoration("Helvetica", NORMAL_STYLE);
322
+ // `fontSize: 0` means auto-size, here as everywhere: resolve it before measuring or drawing.
323
+ const size = this.drawSize(node.style, node.height, capHeight);
324
+ const width = om.getStringWidth(f.label, "Helvetica", size, NORMAL_STYLE);
325
+ const fontNum = this.helv(om);
326
+ const face = pushButtonFace(node.width, node.height, node.style, f.label, width, capHeight, "Helv", size);
327
+ const apRef = om.addFormXObject(`[0 0 ${num2(node.width)} ${num2(node.height)}]`, face, `/Font << /Helv ${fontNum} 0 R >>`);
328
+ const parts = [`/Type /Annot /Subtype /Widget /FT /Btn`, `/T ${om.pdfString(f.name)}`];
329
+ if (f.tooltip !== undefined)
330
+ parts.push(`/TU ${om.pdfString(f.tooltip)}`);
331
+ parts.push(`/Ff ${FF_PUSHBUTTON | commonFlags(f)}`);
332
+ parts.push(`/Rect ${rectOf(node)} /F ${annotFlags(f)}`);
333
+ // /DA as well as the baked /AP: a viewer that REGENERATES the face (poppler does this for push
334
+ // buttons; others do it while the button is pressed) needs the font + size + colour, or it draws the
335
+ // box with no caption. With /DA it reproduces what we baked.
336
+ parts.push(`/DA ${om.pdfString(`/Helv ${num2(size)} Tf ${pdfColor(node.style.color)} rg`)}`);
337
+ parts.push(`/AP << /N ${apRef} 0 R >>`);
338
+ if (f.action)
339
+ parts.push(actionDict(f.action, om));
340
+ // /MK /CA is the caption the viewer falls back to when IT regenerates the face (e.g. while pressed).
341
+ return `<< ${parts.join(" ")}${boxChrome(node, `/CA ${om.pdfString(f.label)}`)} >>`;
342
+ }
343
+ /** A radio button: one KID widget of its group's shared field. The group field is reserved on first
344
+ * sight (and added to /Fields); each button adds itself to that field's /Kids. Returns the kid widget
345
+ * number, which goes into the PAGE /Annots (the parent field does not). */
346
+ addRadio(node, om) {
347
+ const f = node.field;
348
+ if (f.kind !== "radio")
349
+ throw new Error("addRadio: not a radio");
350
+ let g = this.radioGroups.get(f.group);
351
+ if (!g) {
352
+ // Claimed ONCE, when the group comes into being: its buttons share the name deliberately, but the
353
+ // group as a whole still has to collide with a text field of the same name like any other field.
354
+ // A SECOND RadioGroup of the same name therefore joins this one rather than colliding - that is
355
+ // how the buttons of one question are placed in two different spots and stay exclusive.
356
+ this.claimName(f.group, "radio group");
357
+ g = { parentNum: om.addObject(""), kids: [], flags: 0 };
358
+ this.radioGroups.set(f.group, g);
359
+ this.fieldRefs.push(g.parentNum);
360
+ }
361
+ // The flags live on the shared field, so every button contributes: marking any one of them
362
+ // required (or read-only) applies to the group.
363
+ g.flags |= commonFlags(f);
364
+ if (f.selected)
365
+ g.selected = f.value;
366
+ const bbox = `[0 0 ${num2(node.width)} ${num2(node.height)}]`;
367
+ const onRef = om.addFormXObject(bbox, radioOn(node.width, node.height, node.style));
368
+ const offRef = om.addFormXObject(bbox, radioOff(node.width, node.height, node.style));
369
+ const as = f.selected ? f.value : "Off";
370
+ this.zadb(om); // same as a check box: a regenerating viewer draws the dot from /DA + /DR
371
+ const parts = [
372
+ `/Type /Annot /Subtype /Widget /Parent ${g.parentNum} 0 R`,
373
+ `/Rect ${rectOf(node)} /F ${annotFlags(f)}`,
374
+ `/AP << /N << /${escName(f.value)} ${onRef} 0 R /Off ${offRef} 0 R >> >>`,
375
+ `/AS /${escName(as)}`,
376
+ `/DA ${om.pdfString(`/ZaDb 0 Tf ${pdfColor(node.style.color)} rg`)}`,
377
+ ];
378
+ const kidNum = om.addObject(`<< ${parts.join(" ")}${boxChrome(node)} >>`);
379
+ g.kids.push(kidNum);
380
+ return kidNum;
381
+ }
382
+ /** A signature field (/Sig): an EMPTY placeholder with a baked "sign here" face. No /V - an unsigned
383
+ * field has no value; a signing tool fills that in (and replaces this appearance) later. */
384
+ buildSignatureWidget(node, om) {
385
+ const f = node.field;
386
+ if (f.kind !== "signature")
387
+ throw new Error("buildSignatureWidget: not a signature");
388
+ this.hasSignature = true;
389
+ const label = f.label ?? "";
390
+ const { capHeight } = om.getFontDecoration("Helvetica", NORMAL_STYLE);
391
+ const size = this.drawSize(node.style, node.height, capHeight);
392
+ const labelWidth = label ? om.getStringWidth(label, "Helvetica", size, NORMAL_STYLE) : 0;
393
+ const fontNum = this.helv(om);
394
+ const apRef = om.addFormXObject(`[0 0 ${num2(node.width)} ${num2(node.height)}]`, signatureFace(node.width, node.height, node.style, label, labelWidth, "Helv", size), `/Font << /Helv ${fontNum} 0 R >>`);
395
+ const parts = [`/Type /Annot /Subtype /Widget /FT /Sig`, `/T ${om.pdfString(f.name)}`];
396
+ if (f.tooltip !== undefined)
397
+ parts.push(`/TU ${om.pdfString(f.tooltip)}`);
398
+ if (commonFlags(f))
399
+ parts.push(`/Ff ${commonFlags(f)}`);
400
+ parts.push(`/Rect ${rectOf(node)} /F ${annotFlags(f)}`);
401
+ parts.push(`/AP << /N ${apRef} 0 R >>`);
402
+ return `<< ${parts.join(" ")}${boxChrome(node)} >>`;
403
+ }
404
+ finalize(om) {
405
+ // Fill each reserved radio-group field now that all its buttons (Kids) are known.
406
+ for (const [name, g] of this.radioGroups) {
407
+ const kids = g.kids.map((k) => `${k} 0 R`).join(" ");
408
+ const ff = FF_RADIO | FF_NO_TOGGLE_OFF | g.flags;
409
+ om.replaceObject(g.parentNum, `<< /FT /Btn /Ff ${ff} /T ${om.pdfString(name)} /V /${escName(g.selected ?? "Off")} /Kids [${kids}] >>`);
410
+ }
411
+ if (this.fieldRefs.length === 0)
412
+ return "";
413
+ const fields = this.fieldRefs.map((r) => `${r} 0 R`).join(" ");
414
+ // /DR only lists the fonts a field's /DA actually references: /Helv for text, choice and button
415
+ // captions, /ZaDb for the check mark of a box or radio button.
416
+ const fonts = [
417
+ this.helvNum ? `/Helv ${this.helvNum} 0 R` : "",
418
+ this.zadbNum ? `/ZaDb ${this.zadbNum} 0 R` : "",
419
+ ].filter(Boolean);
420
+ const dr = fonts.length ? ` /DR << /Font << ${fonts.join(" ")} >> >>` : "";
421
+ const na = this.needAppearances ? " /NeedAppearances true" : "";
422
+ // /SigFlags 3 = SignaturesExist | AppendOnly: the document holds a signature field, and it must be
423
+ // updated incrementally so an existing signature stays verifiable.
424
+ const sig = this.hasSignature ? " /SigFlags 3" : "";
425
+ return `/AcroForm << /Fields [${fields}]${dr}${na}${sig} >>`;
426
+ }
427
+ }
@@ -0,0 +1,58 @@
1
+ import type { FieldAlign, FieldStyle } from "./field.js";
2
+ import type { FontMetrics } from "../utils/font-metrics.js";
3
+ import type { FontStyle } from "../utils/pdf-object-manager.js";
4
+ /** The unchecked appearance: just the box. */
5
+ export declare function checkboxOff(w: number, h: number, style: FieldStyle): string;
6
+ /** The checked appearance: the box plus a vector checkmark (font-free, so it renders everywhere). */
7
+ export declare function checkboxOn(w: number, h: number, style: FieldStyle): string;
8
+ /** An unselected radio button: just the ring. */
9
+ export declare function radioOff(w: number, h: number, style: FieldStyle): string;
10
+ /** A selected radio button: the ring plus a filled inner dot. */
11
+ export declare function radioOn(w: number, h: number, style: FieldStyle): string;
12
+ /**
13
+ * A push button's face: the box plus its caption, centred. The caller measures the caption (this module
14
+ * stays free of font metrics and the writer) and passes `captionWidth` in points and `capHeight` as an em
15
+ * fraction; the baseline is placed so the CAPITALS sit optically centred, not the em box. `fontRes` is
16
+ * the font's resource name inside the appearance stream's own /Resources.
17
+ */
18
+ export declare function pushButtonFace(w: number, h: number, style: FieldStyle, caption: string, captionWidth: number, capHeight: number, fontRes: string, size: number): string;
19
+ /**
20
+ * An EMPTY signature field: the box, a signing rule across the lower third, and an optional hint centred
21
+ * just above it. This is the placeholder look - once someone actually signs, the signing tool replaces
22
+ * this appearance with its own.
23
+ */
24
+ export declare function signatureFace(w: number, h: number, style: FieldStyle, label: string, labelWidth: number, fontRes: string, size: number): string;
25
+ /** One pre-measured line of field text. The caller owns wrapping + measuring, so this module stays free
26
+ * of font metrics (and can be reused verbatim when filling an existing PDF). */
27
+ /**
28
+ * A field value split into the lines it is DRAWN as.
29
+ *
30
+ * An explicit line break in the value is a line break, and only then does width wrapping apply within
31
+ * each paragraph. Wrapping on width alone ran the paragraphs together - a multi-line field showed
32
+ * "first lineSecond line" - and it did so on BOTH paths, creating a field and filling one, which is why
33
+ * this lives here rather than in either of them.
34
+ */
35
+ export declare function wrapFieldValue(value: string, fontFamily: string, fontSize: number, fontStyle: FontStyle, maxWidth: number, metrics: FontMetrics, multiline: boolean): string[];
36
+ /**
37
+ * The size a field draws its value at. `0` in `/DA` is the PDF convention for "auto, fit the box", and
38
+ * both paths - creating a field and filling one - have to resolve it the SAME way, or the same field
39
+ * comes out at two different sizes depending on which one touched it last.
40
+ */
41
+ export declare function fieldDrawSize(fontSize: number, height: number, borderWidth: number, capHeight: number): number;
42
+ export interface FieldLine {
43
+ text: string;
44
+ width: number;
45
+ /** List box only: draw a selection highlight behind this row. */
46
+ selected?: boolean;
47
+ }
48
+ /**
49
+ * A text field's value, baked. `lines` is already wrapped (one entry for a single-line field). A
50
+ * single line sits optically centred; a multi-line field stacks from the top, both measured off the
51
+ * font's cap height rather than a guessed ratio.
52
+ */
53
+ export declare function textFieldFace(w: number, h: number, style: FieldStyle, lines: FieldLine[], capHeight: number, size: number, fontRes: string, multiline: boolean, align?: FieldAlign): string;
54
+ /**
55
+ * A list box, baked: the visible options stacked from the top, with a highlight behind the selected
56
+ * ones. A combo box (dropdown) uses `textFieldFace` instead - it shows only its current value.
57
+ */
58
+ export declare function listBoxFace(w: number, h: number, style: FieldStyle, lines: FieldLine[], capHeight: number, size: number, fontRes: string, align?: FieldAlign): string;