@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.
- package/README.md +11 -10
- package/dist/api/content.d.ts +5 -4
- package/dist/api/content.js +13 -7
- package/dist/api/descriptor.js +23 -0
- package/dist/api/dimension.d.ts +56 -0
- package/dist/api/dimension.js +27 -0
- package/dist/api/font-url.d.ts +28 -0
- package/dist/api/font-url.js +134 -0
- package/dist/api/forms.d.ts +347 -0
- package/dist/api/forms.js +189 -0
- package/dist/api/gradient.d.ts +53 -0
- package/dist/api/gradient.js +97 -0
- package/dist/api/index.d.ts +3 -0
- package/dist/api/index.js +3 -0
- package/dist/api/insets.d.ts +19 -12
- package/dist/api/insets.js +18 -7
- package/dist/api/layout.d.ts +20 -6
- package/dist/api/layout.js +31 -6
- package/dist/api/page-builder.d.ts +8 -0
- package/dist/api/page-builder.js +12 -0
- package/dist/api/structure.d.ts +11 -0
- package/dist/api/structure.js +17 -4
- package/dist/api/text.d.ts +42 -6
- package/dist/api/text.js +29 -5
- package/dist/crypto/legacy-handler.d.ts +43 -0
- package/dist/crypto/legacy-handler.js +145 -0
- package/dist/crypto/security-handler.d.ts +16 -0
- package/dist/crypto/security-handler.js +26 -0
- package/dist/edit/acroform-reader.d.ts +70 -0
- package/dist/edit/acroform-reader.js +126 -0
- package/dist/edit/appearance.d.ts +47 -0
- package/dist/edit/appearance.js +171 -0
- package/dist/edit/document.d.ts +145 -0
- package/dist/edit/document.js +642 -0
- package/dist/edit/fill.d.ts +65 -0
- package/dist/edit/fill.js +424 -0
- package/dist/edit/flatten.d.ts +54 -0
- package/dist/edit/flatten.js +462 -0
- package/dist/edit/index.d.ts +20 -0
- package/dist/edit/index.js +18 -0
- package/dist/edit/lexer.d.ts +30 -0
- package/dist/edit/lexer.js +285 -0
- package/dist/edit/objects.d.ts +68 -0
- package/dist/edit/objects.js +83 -0
- package/dist/edit/writer.d.ts +60 -0
- package/dist/edit/writer.js +283 -0
- package/dist/elements/container-element.d.ts +21 -1
- package/dist/elements/container-element.js +25 -10
- package/dist/elements/forms/checkbox-element.d.ts +51 -0
- package/dist/elements/forms/checkbox-element.js +48 -0
- package/dist/elements/forms/choice-element.d.ts +53 -0
- package/dist/elements/forms/choice-element.js +59 -0
- package/dist/elements/forms/push-button-element.d.ts +56 -0
- package/dist/elements/forms/push-button-element.js +49 -0
- package/dist/elements/forms/radio-element.d.ts +52 -0
- package/dist/elements/forms/radio-element.js +49 -0
- package/dist/elements/forms/signature-element.d.ts +54 -0
- package/dist/elements/forms/signature-element.js +47 -0
- package/dist/elements/forms/text-field-element.d.ts +61 -0
- package/dist/elements/forms/text-field-element.js +54 -0
- package/dist/elements/image-element.d.ts +18 -4
- package/dist/elements/image-element.js +27 -12
- package/dist/elements/index.d.ts +6 -0
- package/dist/elements/index.js +6 -0
- package/dist/elements/layout/expanded-element.d.ts +3 -1
- package/dist/elements/layout/expanded-element.js +12 -3
- package/dist/elements/layout/padding-element.d.ts +4 -2
- package/dist/elements/layout/padding-element.js +4 -2
- package/dist/elements/layout/page-builder-element.js +2 -0
- package/dist/elements/pdf-element.d.ts +59 -1
- package/dist/elements/pdf-element.js +62 -1
- package/dist/elements/rectangle-element.d.ts +20 -6
- package/dist/elements/rectangle-element.js +25 -12
- package/dist/elements/row-element.d.ts +24 -1
- package/dist/elements/row-element.js +25 -10
- package/dist/elements/text-element.d.ts +60 -1
- package/dist/elements/text-element.js +132 -33
- package/dist/forms/acroform.d.ts +63 -0
- package/dist/forms/acroform.js +427 -0
- package/dist/forms/appearance.d.ts +58 -0
- package/dist/forms/appearance.js +204 -0
- package/dist/forms/field.d.ts +164 -0
- package/dist/forms/field.js +1 -0
- package/dist/forms/pdf.d.ts +19 -0
- package/dist/forms/pdf.js +28 -0
- package/dist/ir/display-list.d.ts +40 -7
- package/dist/ir/display-list.js +8 -1
- package/dist/layout/alignment.d.ts +10 -0
- package/dist/layout/alignment.js +6 -0
- package/dist/layout/box-constraints.d.ts +53 -0
- package/dist/layout/box-constraints.js +72 -0
- package/dist/layout/insets.d.ts +28 -0
- package/dist/layout/insets.js +20 -0
- package/dist/renderer/form-field-renderer.d.ts +20 -0
- package/dist/renderer/form-field-renderer.js +12 -0
- package/dist/renderer/image-renderer.js +2 -1
- package/dist/renderer/page-renderer.js +8 -2
- package/dist/renderer/pdf-backend.d.ts +6 -0
- package/dist/renderer/pdf-backend.js +86 -31
- package/dist/renderer/pdf-document-renderer.js +8 -1
- package/dist/renderer/pdf-renderer.js +19 -1
- package/dist/renderer/rectangle-renderer.js +8 -2
- package/dist/renderer/text-renderer.d.ts +2 -2
- package/dist/renderer/text-renderer.js +150 -42
- package/dist/text/advance.js +5 -2
- package/dist/text/arabic.d.ts +20 -0
- package/dist/text/arabic.js +101 -0
- package/dist/text/bidi.d.ts +33 -0
- package/dist/text/bidi.js +94 -0
- package/dist/text/font-fallback.d.ts +25 -0
- package/dist/text/font-fallback.js +38 -0
- package/dist/text/line-breaker.d.ts +35 -1
- package/dist/text/line-breaker.js +76 -16
- package/dist/text/orphans-widows.d.ts +27 -0
- package/dist/text/orphans-widows.js +53 -0
- package/dist/text/shape.d.ts +29 -0
- package/dist/text/shape.js +67 -0
- package/dist/text/text-style.d.ts +25 -0
- package/dist/text/text-style.js +44 -1
- package/dist/utils/dest-registry.d.ts +5 -2
- package/dist/utils/dest-registry.js +5 -5
- package/dist/utils/flex-layout.d.ts +24 -4
- package/dist/utils/flex-layout.js +169 -7
- package/dist/utils/font-metrics.d.ts +5 -0
- package/dist/utils/gsub.d.ts +36 -0
- package/dist/utils/gsub.js +178 -0
- package/dist/utils/inflate.d.ts +16 -0
- package/dist/utils/inflate.js +52 -0
- package/dist/utils/outline.js +1 -2
- package/dist/utils/pdf-object-manager.d.ts +39 -0
- package/dist/utils/pdf-object-manager.js +166 -15
- package/dist/utils/struct-tree.js +2 -3
- package/dist/utils/ttf-parser.d.ts +6 -0
- package/dist/utils/ttf-parser.js +19 -0
- package/dist/utils/utf8-to-windows1252-encoder.d.ts +6 -0
- package/dist/utils/utf8-to-windows1252-encoder.js +10 -0
- package/dist/utils/woff.d.ts +10 -0
- package/dist/utils/woff.js +116 -0
- package/dist/validators/element-validator.js +14 -3
- 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;
|