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

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 (66) hide show
  1. package/README.md +7 -7
  2. package/dist/api/descriptor.js +23 -0
  3. package/dist/api/forms.d.ts +347 -0
  4. package/dist/api/forms.js +189 -0
  5. package/dist/api/index.d.ts +1 -0
  6. package/dist/api/index.js +1 -0
  7. package/dist/api/structure.d.ts +6 -0
  8. package/dist/api/structure.js +1 -0
  9. package/dist/crypto/legacy-handler.d.ts +43 -0
  10. package/dist/crypto/legacy-handler.js +145 -0
  11. package/dist/crypto/security-handler.d.ts +16 -0
  12. package/dist/crypto/security-handler.js +26 -0
  13. package/dist/edit/acroform-reader.d.ts +70 -0
  14. package/dist/edit/acroform-reader.js +126 -0
  15. package/dist/edit/appearance.d.ts +47 -0
  16. package/dist/edit/appearance.js +171 -0
  17. package/dist/edit/document.d.ts +135 -0
  18. package/dist/edit/document.js +649 -0
  19. package/dist/edit/fill.d.ts +48 -0
  20. package/dist/edit/fill.js +395 -0
  21. package/dist/edit/flatten.d.ts +42 -0
  22. package/dist/edit/flatten.js +437 -0
  23. package/dist/edit/index.d.ts +18 -0
  24. package/dist/edit/index.js +16 -0
  25. package/dist/edit/lexer.d.ts +30 -0
  26. package/dist/edit/lexer.js +285 -0
  27. package/dist/edit/objects.d.ts +63 -0
  28. package/dist/edit/objects.js +56 -0
  29. package/dist/edit/writer.d.ts +53 -0
  30. package/dist/edit/writer.js +261 -0
  31. package/dist/elements/forms/checkbox-element.d.ts +51 -0
  32. package/dist/elements/forms/checkbox-element.js +48 -0
  33. package/dist/elements/forms/choice-element.d.ts +53 -0
  34. package/dist/elements/forms/choice-element.js +59 -0
  35. package/dist/elements/forms/push-button-element.d.ts +56 -0
  36. package/dist/elements/forms/push-button-element.js +49 -0
  37. package/dist/elements/forms/radio-element.d.ts +52 -0
  38. package/dist/elements/forms/radio-element.js +49 -0
  39. package/dist/elements/forms/signature-element.d.ts +54 -0
  40. package/dist/elements/forms/signature-element.js +47 -0
  41. package/dist/elements/forms/text-field-element.d.ts +61 -0
  42. package/dist/elements/forms/text-field-element.js +54 -0
  43. package/dist/elements/index.d.ts +6 -0
  44. package/dist/elements/index.js +6 -0
  45. package/dist/forms/acroform.d.ts +63 -0
  46. package/dist/forms/acroform.js +427 -0
  47. package/dist/forms/appearance.d.ts +58 -0
  48. package/dist/forms/appearance.js +204 -0
  49. package/dist/forms/field.d.ts +164 -0
  50. package/dist/forms/field.js +1 -0
  51. package/dist/forms/pdf.d.ts +19 -0
  52. package/dist/forms/pdf.js +28 -0
  53. package/dist/ir/display-list.d.ts +17 -1
  54. package/dist/renderer/form-field-renderer.d.ts +20 -0
  55. package/dist/renderer/form-field-renderer.js +12 -0
  56. package/dist/renderer/page-renderer.js +8 -2
  57. package/dist/renderer/pdf-backend.js +8 -0
  58. package/dist/renderer/pdf-renderer.js +19 -1
  59. package/dist/utils/dest-registry.d.ts +5 -2
  60. package/dist/utils/dest-registry.js +5 -5
  61. package/dist/utils/outline.js +1 -2
  62. package/dist/utils/pdf-object-manager.d.ts +18 -0
  63. package/dist/utils/pdf-object-manager.js +59 -6
  64. package/dist/utils/struct-tree.js +2 -3
  65. package/dist/validators/element-validator.js +7 -3
  66. package/package.json +9 -2
@@ -0,0 +1,204 @@
1
+ import { escPdf, num2, pdfColor } from "./pdf.js";
2
+ import { wrapStringIntoLines } from "../text/line-breaker.js";
3
+ // Appearance streams (/AP): the actual drawn content of a field's box, in the field's OWN coordinate
4
+ // space (bottom-left origin, [0 0 w h]). Shared between CREATE (bake the field's look now) and, later,
5
+ // FILL-existing (regenerate a field's look after setting its value). This is why it is its own module.
6
+ /** Draw the field box: background fill + border stroke, inset so the stroke stays inside the BBox. */
7
+ function box(w, h, style) {
8
+ const ops = [];
9
+ if (style.background)
10
+ ops.push(`${pdfColor(style.background)} rg 0 0 ${num2(w)} ${num2(h)} re f`);
11
+ if (style.border && style.borderWidth > 0) {
12
+ const bw = style.borderWidth;
13
+ const i = bw / 2;
14
+ ops.push(`${pdfColor(style.border)} RG ${num2(bw)} w ${num2(i)} ${num2(i)} ${num2(w - bw)} ${num2(h - bw)} re S`);
15
+ }
16
+ return ops.join("\n");
17
+ }
18
+ /** The unchecked appearance: just the box. */
19
+ export function checkboxOff(w, h, style) {
20
+ return box(w, h, style);
21
+ }
22
+ /** The checked appearance: the box plus a vector checkmark (font-free, so it renders everywhere). */
23
+ export function checkboxOn(w, h, style) {
24
+ const lw = Math.max(1, Math.min(w, h) * 0.12);
25
+ // A tick: down-stroke to the low point, then up-stroke to the high point. Round caps/joins (2 J / 1 j).
26
+ const p = (fx, fy) => `${num2(fx * w)} ${num2(fy * h)}`;
27
+ const tick = `${pdfColor(style.color)} RG ${num2(lw)} w 2 J 1 j ` +
28
+ `${p(0.22, 0.52)} m ${p(0.42, 0.3)} l ${p(0.78, 0.74)} l S`;
29
+ return `${box(w, h, style)}\n${tick}`;
30
+ }
31
+ // A circle path (4 cubic-bezier arcs, kappa = 0.5523) around (cx, cy) with radius r. Not stroked/filled -
32
+ // the caller appends `S` (stroke) or `f` (fill).
33
+ function circlePath(cx, cy, r) {
34
+ const k = 0.5523 * r;
35
+ const n = num2;
36
+ return (`${n(cx + r)} ${n(cy)} m ` +
37
+ `${n(cx + r)} ${n(cy + k)} ${n(cx + k)} ${n(cy + r)} ${n(cx)} ${n(cy + r)} c ` +
38
+ `${n(cx - k)} ${n(cy + r)} ${n(cx - r)} ${n(cy + k)} ${n(cx - r)} ${n(cy)} c ` +
39
+ `${n(cx - r)} ${n(cy - k)} ${n(cx - k)} ${n(cy - r)} ${n(cx)} ${n(cy - r)} c ` +
40
+ `${n(cx + k)} ${n(cy - r)} ${n(cx + r)} ${n(cy - k)} ${n(cx + r)} ${n(cy)} c`);
41
+ }
42
+ /** The ring of a radio button (the outline), in [0 0 w h]. */
43
+ function radioRing(w, h, style) {
44
+ const r = Math.min(w, h) / 2;
45
+ const bw = style.borderWidth > 0 ? style.borderWidth : 1;
46
+ const ops = [];
47
+ if (style.background)
48
+ ops.push(`${pdfColor(style.background)} rg ${circlePath(w / 2, h / 2, r)} f`);
49
+ const stroke = style.border ?? style.color;
50
+ ops.push(`${pdfColor(stroke)} RG ${num2(bw)} w ${circlePath(w / 2, h / 2, r - bw / 2)} S`);
51
+ return ops.join("\n");
52
+ }
53
+ /** An unselected radio button: just the ring. */
54
+ export function radioOff(w, h, style) {
55
+ return radioRing(w, h, style);
56
+ }
57
+ /** A selected radio button: the ring plus a filled inner dot. */
58
+ export function radioOn(w, h, style) {
59
+ const r = Math.min(w, h) / 2;
60
+ const dot = `${pdfColor(style.color)} rg ${circlePath(w / 2, h / 2, r * 0.45)} f`;
61
+ return `${radioRing(w, h, style)}\n${dot}`;
62
+ }
63
+ /**
64
+ * A push button's face: the box plus its caption, centred. The caller measures the caption (this module
65
+ * stays free of font metrics and the writer) and passes `captionWidth` in points and `capHeight` as an em
66
+ * fraction; the baseline is placed so the CAPITALS sit optically centred, not the em box. `fontRes` is
67
+ * the font's resource name inside the appearance stream's own /Resources.
68
+ */
69
+ export function pushButtonFace(w, h, style, caption, captionWidth, capHeight, fontRes, size) {
70
+ // Baseline placed so the CAPITALS sit optically centred in the box, not the em square.
71
+ const y = (h - capHeight * size) / 2;
72
+ const caption_ = caption ? centredText(w, h, style, caption, captionWidth, y, fontRes, size) : "";
73
+ return `${box(w, h, style)}\n${caption_}`;
74
+ }
75
+ /** One centred, clipped text run inside a field box. Clipping means an over-long caption is cut at the
76
+ * border instead of bleeding across the page. Shared by the push button and the signature placeholder. */
77
+ function centredText(w, h, style, text, textWidth, baselineY, fontRes, size) {
78
+ return (`q 0 0 ${num2(w)} ${num2(h)} re W n ` +
79
+ `BT /${fontRes} ${num2(size)} Tf ${pdfColor(style.color)} rg ` +
80
+ `${num2((w - textWidth) / 2)} ${num2(baselineY)} Td (${escPdf(text)}) Tj ET Q`);
81
+ }
82
+ /**
83
+ * An EMPTY signature field: the box, a signing rule across the lower third, and an optional hint centred
84
+ * just above it. This is the placeholder look - once someone actually signs, the signing tool replaces
85
+ * this appearance with its own.
86
+ */
87
+ export function signatureFace(w, h, style, label, labelWidth, fontRes, size) {
88
+ const ruleY = h * 0.3;
89
+ const inset = w * 0.08;
90
+ const stroke = style.border ?? style.color;
91
+ const rule = `${pdfColor(stroke)} RG ${num2(Math.max(0.5, style.borderWidth * 0.75))} w ` +
92
+ `${num2(inset)} ${num2(ruleY)} m ${num2(w - inset)} ${num2(ruleY)} l S`;
93
+ const hint = label ? centredText(w, h, style, label, labelWidth, ruleY + 6, fontRes, size) : "";
94
+ return `${box(w, h, style)}\n${rule}\n${hint}`;
95
+ }
96
+ // Horizontal padding inside a field box, on top of the border - the same small inset Acrobat leaves so
97
+ // the value does not touch the frame.
98
+ const FIELD_PAD = 2;
99
+ // A list box highlights its selected rows. Viewers each pick their own colour; once we bake the
100
+ // appearance WE decide, and this light blue is the familiar selection tint.
101
+ const SELECTION_TINT = "0.60 0.75 0.95";
102
+ /** One pre-measured line of field text. The caller owns wrapping + measuring, so this module stays free
103
+ * of font metrics (and can be reused verbatim when filling an existing PDF). */
104
+ /**
105
+ * A field value split into the lines it is DRAWN as.
106
+ *
107
+ * An explicit line break in the value is a line break, and only then does width wrapping apply within
108
+ * each paragraph. Wrapping on width alone ran the paragraphs together - a multi-line field showed
109
+ * "first lineSecond line" - and it did so on BOTH paths, creating a field and filling one, which is why
110
+ * this lives here rather than in either of them.
111
+ */
112
+ export function wrapFieldValue(value, fontFamily, fontSize, fontStyle, maxWidth, metrics, multiline) {
113
+ if (!multiline)
114
+ return [value.replace(/\r\n?|\n/g, " ")];
115
+ // CR, LF and CRLF all mean one break (PDF 7.3.4.2 says the same about a literal string).
116
+ return value
117
+ .split(/\r\n?|\n/)
118
+ .flatMap((para) => para === ""
119
+ ? [""]
120
+ : wrapStringIntoLines(para, fontFamily, fontSize, fontStyle, maxWidth, metrics));
121
+ }
122
+ /**
123
+ * The size a field draws its value at. `0` in `/DA` is the PDF convention for "auto, fit the box", and
124
+ * both paths - creating a field and filling one - have to resolve it the SAME way, or the same field
125
+ * comes out at two different sizes depending on which one touched it last.
126
+ */
127
+ export function fieldDrawSize(fontSize, height, borderWidth, capHeight) {
128
+ if (fontSize > 0)
129
+ return fontSize;
130
+ const available = height - 2 * (2 + borderWidth);
131
+ return Math.max(4, Math.min(12, (available * 0.7) / (capHeight || 0.7)));
132
+ }
133
+ /** Where a line of `width` starts, given the box, its padding and the requested alignment. */
134
+ function alignX(boxWidth, pad, width, align = "left") {
135
+ if (align === "center")
136
+ return (boxWidth - width) / 2;
137
+ if (align === "right")
138
+ return boxWidth - pad - width;
139
+ return pad;
140
+ }
141
+ /**
142
+ * `BT … ET` for absolutely-placed lines. `Tm` (not `Td`) so each line's position is independent.
143
+ *
144
+ * Field text is drawn UNKERNED (a plain `Tj`, never a `TJ` array), even though ordinary document text
145
+ * kerns by default. That is deliberate: the moment someone clicks into a field, the viewer regenerates
146
+ * this appearance from `/DA` - and viewers do not kern field text. Kerning here would make the value
147
+ * visibly shift on first click. The measurement matches: `getStringWidth` is the plain glyph sum, so
148
+ * measured still equals drawn.
149
+ */
150
+ function textBlock(lines, style, size, fontRes) {
151
+ const runs = lines
152
+ .map((l) => `1 0 0 1 ${num2(l.x)} ${num2(l.y)} Tm (${escPdf(l.text)}) Tj`)
153
+ .join("\n");
154
+ return `BT /${fontRes} ${num2(size)} Tf ${pdfColor(style.color)} rg\n${runs}\nET`;
155
+ }
156
+ /** Everything a field draws is clipped to its own box, so a long value is cut at the frame rather than
157
+ * bleeding across the page. */
158
+ function clipped(w, h, ops) {
159
+ return `q 0 0 ${num2(w)} ${num2(h)} re W n\n${ops}\nQ`;
160
+ }
161
+ /**
162
+ * A text field's value, baked. `lines` is already wrapped (one entry for a single-line field). A
163
+ * single line sits optically centred; a multi-line field stacks from the top, both measured off the
164
+ * font's cap height rather than a guessed ratio.
165
+ */
166
+ export function textFieldFace(w, h, style, lines, capHeight, size, fontRes, multiline, align = "left") {
167
+ const face = box(w, h, style);
168
+ if (lines.length === 0)
169
+ return face;
170
+ const pad = FIELD_PAD + style.borderWidth;
171
+ const lineHeight = size * 1.15;
172
+ const placed = lines.map((l, i) => ({
173
+ text: l.text,
174
+ x: alignX(w, pad, l.width, align),
175
+ // Multi-line: the first line's capitals start one cap-height below the top inset, then step down.
176
+ // Single line: centre the capitals in the box.
177
+ y: multiline ? h - pad - capHeight * size - i * lineHeight : (h - capHeight * size) / 2,
178
+ }));
179
+ return `${face}\n${clipped(w, h, textBlock(placed, style, size, fontRes))}`;
180
+ }
181
+ /**
182
+ * A list box, baked: the visible options stacked from the top, with a highlight behind the selected
183
+ * ones. A combo box (dropdown) uses `textFieldFace` instead - it shows only its current value.
184
+ */
185
+ export function listBoxFace(w, h, style, lines, capHeight, size, fontRes, align = "left") {
186
+ const pad = FIELD_PAD + style.borderWidth;
187
+ const lineHeight = size * 1.15;
188
+ const rowTop = (i) => h - pad - (i + 1) * lineHeight;
189
+ // Highlights first, so the text sits on top of them.
190
+ const highlights = lines
191
+ .map((l, i) => l.selected
192
+ ? `${SELECTION_TINT} rg ${num2(pad / 2)} ${num2(rowTop(i))} ${num2(w - pad)} ${num2(lineHeight)} re f`
193
+ : "")
194
+ .filter(Boolean)
195
+ .join("\n");
196
+ const placed = lines.map((l, i) => ({
197
+ text: l.text,
198
+ x: alignX(w, pad, l.width, align),
199
+ // Baseline inside the row: lift it off the row bottom by the slack under the capitals.
200
+ y: rowTop(i) + (lineHeight - capHeight * size) / 2,
201
+ }));
202
+ const inner = [highlights, textBlock(placed, style, size, fontRes)].filter(Boolean).join("\n");
203
+ return `${box(w, h, style)}\n${clipped(w, h, inner)}`;
204
+ }
@@ -0,0 +1,164 @@
1
+ import type { Color } from "../common/color.js";
2
+ /**
3
+ * The semantic description of ONE AcroForm field, independent of whether it is being CREATED (the field
4
+ * elements produce it) or FILLED in an existing PDF (the reader parses existing fields into it, later).
5
+ * This shared model is the spine that makes "create" and "fill existing" two parts of one feature.
6
+ *
7
+ * A discriminated union, one `kind` per PDF field family (/Tx, /Btn, /Ch, /Sig) - all of them
8
+ * implemented, so a `switch` over `kind` is exhaustive.
9
+ */
10
+ export type FormFieldSpec = TextFieldSpec | CheckboxSpec | RadioSpec | ChoiceSpec | PushButtonSpec | SignatureSpec;
11
+ /**
12
+ * What every field kind shares, whatever its family. Kept in one place so a new field type cannot
13
+ * silently miss them.
14
+ */
15
+ export interface CommonFieldProps {
16
+ /** A tooltip / accessible name (/TU). */
17
+ tooltip?: string;
18
+ /** Show the field but do not let it be changed. */
19
+ readOnly?: boolean;
20
+ /** Mark it as a field that must be filled in before the form is submitted (/Ff Required). */
21
+ required?: boolean;
22
+ /** Hide the widget entirely - neither on screen nor in print. */
23
+ hidden?: boolean;
24
+ /** Include the widget when printing (default true). `false` keeps it on screen only. */
25
+ print?: boolean;
26
+ }
27
+ /** How a value is aligned inside its field box (the PDF calls this "quadding", /Q). */
28
+ export type FieldAlign = "left" | "center" | "right";
29
+ /**
30
+ * A variable text field (/Tx). The PDF variants live in field flags; here they are typed props, so you
31
+ * never hand-assemble a `/Ff` bitmask.
32
+ */
33
+ export interface TextFieldSpec extends CommonFieldProps {
34
+ kind: "text";
35
+ /** The field's name (/T) - what you read the value back by. Must be unique in the document. */
36
+ name: string;
37
+ /** The current text value (/V). */
38
+ value?: string;
39
+ /** A tooltip / accessible name for the field (/TU). */
40
+ tooltip?: string;
41
+ /** Wrap across multiple lines instead of a single line (the Multiline flag). */
42
+ multiline?: boolean;
43
+ /** Mask the typed characters (the Password flag). */
44
+ password?: boolean;
45
+ /** How the value sits in the box (default left). Applies to the baked appearance AND /Q. */
46
+ align?: FieldAlign;
47
+ /** Maximum number of characters the field accepts (/MaxLen). */
48
+ maxLength?: number;
49
+ }
50
+ /**
51
+ * A checkbox (/Btn, neither the Radio nor the Pushbutton flag). Its value is the "on" export name when
52
+ * checked and `Off` when not - PDF checkboxes use NAMES, not strings; we hide that behind `checked`.
53
+ */
54
+ export interface CheckboxSpec extends CommonFieldProps {
55
+ kind: "checkbox";
56
+ /** The field name (/T). */
57
+ name: string;
58
+ /** Whether it starts checked. */
59
+ checked?: boolean;
60
+ /** The "on" export value (the /AP state name + /V when checked). Default `"Yes"`. */
61
+ onValue?: string;
62
+ }
63
+ /**
64
+ * One radio button (/Btn with the Radio flag). Every radio sharing a `group` is ONE AcroForm field whose
65
+ * `/Kids` are the individual buttons - mutually exclusive, so picking one clears the rest. `value` is this
66
+ * button's export name (unique within the group); it becomes the field's value when this one is picked.
67
+ */
68
+ export interface RadioSpec extends CommonFieldProps {
69
+ kind: "radio";
70
+ /** The shared group name (/T of the parent field). All radios with the same `group` are one field. */
71
+ group: string;
72
+ /** This button's export value - unique in the group; the field's /V when this one is selected. */
73
+ value: string;
74
+ /** Whether this button starts selected (at most one per group should be). */
75
+ selected?: boolean;
76
+ }
77
+ /** One entry of a choice field. `label` is what the reader sees; `value` is what gets stored. */
78
+ export interface ChoiceOption {
79
+ value: string;
80
+ label?: string;
81
+ }
82
+ /**
83
+ * A choice field (/Ch): a dropdown (combo box) or a list box. `combo` picks which. The value(s) are
84
+ * export strings from `options`. A combo may be `editable` (the reader can type a value not in the list);
85
+ * a list box may allow `multiSelect`.
86
+ */
87
+ export interface ChoiceSpec extends CommonFieldProps {
88
+ kind: "choice";
89
+ /** The field name (/T). */
90
+ name: string;
91
+ /** The selectable options. */
92
+ options: ChoiceOption[];
93
+ /** `true` = dropdown (combo box), `false` = list box. */
94
+ combo: boolean;
95
+ /** Combo only: let the reader type a value that is not in the list. */
96
+ editable?: boolean;
97
+ /** List box only: allow more than one selection. */
98
+ multiSelect?: boolean;
99
+ /** The selected value (single-select). */
100
+ value?: string;
101
+ /** How the value sits in the box (default left). */
102
+ align?: FieldAlign;
103
+ /** The selected values (a `multiSelect` list box). */
104
+ values?: string[];
105
+ /** Tooltip / accessible name. */
106
+ tooltip?: string;
107
+ }
108
+ /**
109
+ * What a push button does when clicked. A typed union, so an action can never be half-specified (a
110
+ * submit without a target is not expressible). Scripted (JavaScript) actions are deliberately out of
111
+ * scope - a jasy form is data, not a program.
112
+ */
113
+ export type ButtonAction = {
114
+ kind: "reset";
115
+ } | {
116
+ kind: "submit";
117
+ url: string;
118
+ } | {
119
+ kind: "url";
120
+ url: string;
121
+ };
122
+ /**
123
+ * A push button (/Btn with the Pushbutton flag). Unlike every other field it holds NO value - it is a
124
+ * click target that fires an action. Its caption is baked into the appearance stream, so the button
125
+ * looks the same in every viewer and in print.
126
+ */
127
+ export interface PushButtonSpec extends CommonFieldProps {
128
+ kind: "pushbutton";
129
+ /** The field name (/T). */
130
+ name: string;
131
+ /** The caption drawn on the button. */
132
+ label: string;
133
+ /** What clicking it does; omit for a button that does nothing (a plain label). */
134
+ action?: ButtonAction;
135
+ }
136
+ /**
137
+ * A signature field (/Sig) - a PLACEHOLDER where a signature can later be applied. jasy creates the
138
+ * field and its empty appearance; it does not sign (real signing needs a certificate and a byte-range
139
+ * digest, a separate feature). An unsigned field has no /V; a signer fills that in later.
140
+ */
141
+ export interface SignatureSpec extends CommonFieldProps {
142
+ kind: "signature";
143
+ /** The field name (/T). */
144
+ name: string;
145
+ /** A hint drawn inside the empty box, e.g. "Signature" or the signer's role. */
146
+ label?: string;
147
+ }
148
+ /**
149
+ * How a field's WIDGET is painted: the box (border + fill) and how its value looks (font size + colour).
150
+ * Kept separate from the semantic spec because it maps to the widget `/MK` + `/DA`, not the field value -
151
+ * and the fill-existing path reuses it to restyle a field it did not create.
152
+ */
153
+ export interface FieldStyle {
154
+ /** Border (stroke) colour of the field box. Omit for no border. */
155
+ border?: Color;
156
+ /** Background fill of the field box. Omit for transparent. */
157
+ background?: Color;
158
+ /** The value's text colour (drawn into `/DA`). */
159
+ color: Color;
160
+ /** Font size in points; `0` lets the viewer auto-size to the box (the PDF convention). */
161
+ fontSize: number;
162
+ /** Border thickness in points (only drawn when a `border` is set). */
163
+ borderWidth: number;
164
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,19 @@
1
+ import type { Color } from "../common/color.js";
2
+ import type { FontStyle } from "../utils/pdf-object-manager.js";
3
+ /** The regular face, for measuring a field's built-in Helvetica. `FontStyle` is a string enum, so this
4
+ * is its exact value - asserted here once instead of importing the enum (which would be a runtime
5
+ * import of the writer, see above). */
6
+ export declare const NORMAL_STYLE: FontStyle;
7
+ /** Escape a PDF literal string: backslash first, then the string delimiters. */
8
+ export declare const escPdf: (s: string) => string;
9
+ /** Trim a number to 2 decimals (PDF operands). */
10
+ export declare const num2: (n: number) => number;
11
+ /** A colour as PDF operands "r g b" in 0..1 (Color stores 0..255). */
12
+ export declare const pdfColor: (c: Color) => string;
13
+ /**
14
+ * Escape a PDF **Name** token (`/Yes`, `/Off`, a checkbox's export value). A Name may only hold the
15
+ * regular characters; whitespace, the delimiters `()<>[]{}/%` and `#` itself must be written `#XX` by
16
+ * BYTE. Without this, an export value like `"Ja / Nein"` would break the dictionary it sits in.
17
+ * Literal strings - `(text)` - use `escPdf` instead; the two escapes are not interchangeable.
18
+ */
19
+ export declare const escName: (s: string) => string;
@@ -0,0 +1,28 @@
1
+ // Small PDF-writer helpers shared across the forms module (widget dicts + appearance streams).
2
+ //
3
+ // Note: everything under `forms/` imports the object manager TYPE-ONLY. Keeping the runtime dependency
4
+ // one-way (the writer owns a collector, never the reverse) avoids an ESM import cycle.
5
+ /** The regular face, for measuring a field's built-in Helvetica. `FontStyle` is a string enum, so this
6
+ * is its exact value - asserted here once instead of importing the enum (which would be a runtime
7
+ * import of the writer, see above). */
8
+ export const NORMAL_STYLE = "normal";
9
+ /** Escape a PDF literal string: backslash first, then the string delimiters. */
10
+ export const escPdf = (s) => s.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)");
11
+ /** Trim a number to 2 decimals (PDF operands). */
12
+ export const num2 = (n) => Number(n.toFixed(2));
13
+ /** A colour as PDF operands "r g b" in 0..1 (Color stores 0..255). */
14
+ export const pdfColor = (c) => c
15
+ .toArray()
16
+ .map((v) => (v / 255).toFixed(3))
17
+ .join(" ");
18
+ /**
19
+ * Escape a PDF **Name** token (`/Yes`, `/Off`, a checkbox's export value). A Name may only hold the
20
+ * regular characters; whitespace, the delimiters `()<>[]{}/%` and `#` itself must be written `#XX` by
21
+ * BYTE. Without this, an export value like `"Ja / Nein"` would break the dictionary it sits in.
22
+ * Literal strings - `(text)` - use `escPdf` instead; the two escapes are not interchangeable.
23
+ */
24
+ export const escName = (s) => Array.from(new TextEncoder().encode(s))
25
+ .map((b) => b >= 0x21 && b <= 0x7e && !"()<>[]{}/%#".includes(String.fromCharCode(b))
26
+ ? String.fromCharCode(b)
27
+ : "#" + b.toString(16).padStart(2, "0").toUpperCase())
28
+ .join("");
@@ -1,5 +1,6 @@
1
1
  import { Color } from "../common/color.js";
2
2
  import { FontStyle } from "../utils/pdf-object-manager.js";
3
+ import type { FormFieldSpec, FieldStyle } from "../forms/field.js";
3
4
  /**
4
5
  * Display list - the seam between layout and the PDF backend.
5
6
  *
@@ -145,6 +146,21 @@ export interface Outline {
145
146
  title: string;
146
147
  level: number;
147
148
  }
149
+ /**
150
+ * An interactive form field (AcroForm). Like `Link` it draws NOTHING into the content stream - it becomes
151
+ * a Widget ANNOTATION on the page (`/Annots`) and a field in the catalog `/AcroForm`, both built from the
152
+ * shared `FormFieldSpec`. The rect is the field box in top-left engine coords; the Y-flip converts it to
153
+ * page space. See `forms/acroform.ts` for the widget/field assembly.
154
+ */
155
+ export interface FormFieldNode {
156
+ type: "formfield";
157
+ x: number;
158
+ y: number;
159
+ width: number;
160
+ height: number;
161
+ field: FormFieldSpec;
162
+ style: FieldStyle;
163
+ }
148
164
  /**
149
165
  * Pushes an affine transform (a `q` + `cm` in the content stream): everything between this and the
150
166
  * matching `TransformPop` is painted through `matrix`. This is what `Rotated` wraps its child's nodes
@@ -227,4 +243,4 @@ export interface Path {
227
243
  tag?: StructTag;
228
244
  }
229
245
  /** The closed set of primitives the PDF backend knows how to draw. */
230
- export type IRNode = TextRun | Rect | Line | Image | ClipPush | ClipPop | Path | Link | Outline | Anchor | TransformPush | TransformPop;
246
+ export type IRNode = TextRun | Rect | Line | Image | ClipPush | ClipPop | Path | Link | Outline | Anchor | FormFieldNode | TransformPush | TransformPop;
@@ -0,0 +1,20 @@
1
+ import { PDFObjectManager } from "../utils/pdf-object-manager.js";
2
+ import { TextFieldElement } from "../elements/forms/text-field-element.js";
3
+ import { CheckboxElement } from "../elements/forms/checkbox-element.js";
4
+ import { RadioElement } from "../elements/forms/radio-element.js";
5
+ import { ChoiceElement } from "../elements/forms/choice-element.js";
6
+ import { PushButtonElement } from "../elements/forms/push-button-element.js";
7
+ import { SignatureFieldElement } from "../elements/forms/signature-element.js";
8
+ import { IRNode } from "../ir/display-list.js";
9
+ /** Any form-field element exposes its box + shared spec + style through `getProps()`. */
10
+ type FormFieldElement = TextFieldElement | CheckboxElement | RadioElement | ChoiceElement | PushButtonElement | SignatureFieldElement;
11
+ /**
12
+ * Renders a form-field element to a single `formfield` IR node - the field's box + its shared
13
+ * `FormFieldSpec` + style. It draws nothing; the PageRenderer peels it into a Widget /Annot and the
14
+ * /AcroForm (via forms/acroform.ts). One renderer serves every field element (the spec's `kind`
15
+ * discriminates downstream).
16
+ */
17
+ export declare class FormFieldRenderer {
18
+ static render(element: FormFieldElement, _objectManager: PDFObjectManager): Promise<IRNode[]>;
19
+ }
20
+ export {};
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Renders a form-field element to a single `formfield` IR node - the field's box + its shared
3
+ * `FormFieldSpec` + style. It draws nothing; the PageRenderer peels it into a Widget /Annot and the
4
+ * /AcroForm (via forms/acroform.ts). One renderer serves every field element (the spec's `kind`
5
+ * discriminates downstream).
6
+ */
7
+ export class FormFieldRenderer {
8
+ static async render(element, _objectManager) {
9
+ const { x, y, width, height, spec, style } = element.getProps();
10
+ return [{ type: "formfield", x, y, width, height, field: spec, style }];
11
+ }
12
+ }
@@ -74,14 +74,20 @@ export class PageRenderer {
74
74
  const num = (n) => Number(n.toFixed(2));
75
75
  const annotRefs = [];
76
76
  for (const node of flipped) {
77
+ // A form field: build its Widget annotation + register it as an /AcroForm field (the logic lives in
78
+ // forms/acroform.ts), then reference it here in the page /Annots - the same side channel as a link.
79
+ if (node.type === "formfield") {
80
+ annotRefs.push(`${objectManager.acroform.addField(node, objectManager)} 0 R`);
81
+ continue;
82
+ }
77
83
  if (node.type !== "link")
78
84
  continue;
79
85
  const rect = `[${num(node.x)} ${num(node.y)} ${num(node.x + node.width)} ${num(node.y + node.height)}]`;
80
86
  // Either an external URL (/URI) or an internal named destination (/GoTo, resolved via /Names /Dests).
81
87
  // The Link IR guarantees exactly one is set; prefer `dest` when present.
82
88
  const action = node.dest !== undefined
83
- ? `/A << /S /GoTo /D (${PdfBackend.escapePdfString(node.dest)}) >>`
84
- : `/A << /S /URI /URI (${PdfBackend.escapePdfString(node.href ?? "")}) >>`;
89
+ ? `/A << /S /GoTo /D ${objectManager.pdfString(node.dest)} >>`
90
+ : `/A << /S /URI /URI ${objectManager.pdfString(node.href ?? "")} >>`;
85
91
  const annot = objectManager.addObject(`<< /Type /Annot /Subtype /Link /Rect ${rect} /Border [0 0 0] ${action} >>`);
86
92
  annotRefs.push(`${annot} 0 R`);
87
93
  }
@@ -65,6 +65,9 @@ export class PdfBackend {
65
65
  case "link":
66
66
  // A link's clickable rect flips around its bottom edge, exactly like a rect.
67
67
  return { ...node, y: pageHeight - node.y - node.height };
68
+ case "formfield":
69
+ // A form field's box flips around its bottom edge, exactly like a link rect.
70
+ return { ...node, y: pageHeight - node.y - node.height };
68
71
  case "outline":
69
72
  // An outline anchor is a single point (the target's top); flip it like a text baseline.
70
73
  return { ...node, y: pageHeight - node.y };
@@ -137,6 +140,7 @@ export class PdfBackend {
137
140
  node.type === "link" ||
138
141
  node.type === "outline" ||
139
142
  node.type === "anchor" ||
143
+ node.type === "formfield" ||
140
144
  ops === "")
141
145
  return ops;
142
146
  const { open, close } = struct.mark(node.tag);
@@ -357,6 +361,10 @@ export class PdfBackend {
357
361
  case "anchor":
358
362
  // A named destination draws nothing - it becomes a /Names /Dests entry (built in PageRenderer/PDFRenderer).
359
363
  return "";
364
+ case "formfield":
365
+ // A form field draws nothing in the content stream - it becomes a Widget /Annot + /AcroForm field
366
+ // (built in PageRenderer via forms/acroform.ts).
367
+ return "";
360
368
  default: {
361
369
  // Exhaustiveness guard: if a new IRNode variant is added, this fails to compile.
362
370
  const unknown = node;
@@ -31,6 +31,13 @@ import { AnchorRenderer } from "./anchor-renderer.js";
31
31
  import { PageBreakRenderer } from "./page-break-renderer.js";
32
32
  import { KeepTogetherElement } from "../elements/layout/keep-together-element.js";
33
33
  import { KeepTogetherRenderer } from "./keep-together-renderer.js";
34
+ import { TextFieldElement } from "../elements/forms/text-field-element.js";
35
+ import { CheckboxElement } from "../elements/forms/checkbox-element.js";
36
+ import { RadioElement } from "../elements/forms/radio-element.js";
37
+ import { ChoiceElement } from "../elements/forms/choice-element.js";
38
+ import { PushButtonElement } from "../elements/forms/push-button-element.js";
39
+ import { SignatureFieldElement } from "../elements/forms/signature-element.js";
40
+ import { FormFieldRenderer } from "./form-field-renderer.js";
34
41
  import { PageBuilderElement } from "../elements/layout/page-builder-element.js";
35
42
  import { PageBuilderRenderer } from "./page-builder-renderer.js";
36
43
  import { RotatedElement } from "../elements/layout/rotated-element.js";
@@ -51,6 +58,12 @@ export class PDFRenderer {
51
58
  RendererRegistry.register(PaddingElement, PaddingRenderer.render);
52
59
  RendererRegistry.register(PageBreakElement, PageBreakRenderer.render);
53
60
  RendererRegistry.register(KeepTogetherElement, KeepTogetherRenderer.render);
61
+ RendererRegistry.register(TextFieldElement, FormFieldRenderer.render);
62
+ RendererRegistry.register(CheckboxElement, FormFieldRenderer.render);
63
+ RendererRegistry.register(RadioElement, FormFieldRenderer.render);
64
+ RendererRegistry.register(ChoiceElement, FormFieldRenderer.render);
65
+ RendererRegistry.register(PushButtonElement, FormFieldRenderer.render);
66
+ RendererRegistry.register(SignatureFieldElement, FormFieldRenderer.render);
54
67
  RendererRegistry.register(DefaultTextStyleElement, DefaultTextStyleRenderer.render);
55
68
  RendererRegistry.register(ImageElement, ImageRenderer.render);
56
69
  RendererRegistry.register(LineElement, LineRenderer.render);
@@ -124,8 +137,13 @@ export class PDFRenderer {
124
137
  const outlineCatalog = objectManager.outline.finalize(objectManager);
125
138
  if (outlineCatalog)
126
139
  catalogParts.push(outlineCatalog);
140
+ // Interactive form fields: emit the catalog /AcroForm dict collected as pages rendered. Returns ""
141
+ // (no-op) when no field was placed, so a plain document's catalog is unchanged.
142
+ const acroformCatalog = objectManager.acroform.finalize(objectManager);
143
+ if (acroformCatalog)
144
+ catalogParts.push(acroformCatalog);
127
145
  // Named destinations (internal-link targets): a /Dests entry in the shared /Names dict.
128
- const destsNames = objectManager.dests.finalize();
146
+ const destsNames = objectManager.dests.finalize(objectManager);
129
147
  if (destsNames)
130
148
  namesParts.push(destsNames);
131
149
  // Emit the single /Names dict if either embedded files or destinations contributed to it.
@@ -1,3 +1,4 @@
1
+ import type { PDFObjectManager } from "./pdf-object-manager.js";
1
2
  interface Dest {
2
3
  pageObjNum: number;
3
4
  top: number;
@@ -13,7 +14,9 @@ export declare class DestRegistry {
13
14
  add(name: string, dest: Dest): void;
14
15
  get isEmpty(): boolean;
15
16
  /** The `/Dests << /Names [ ... ] >>` fragment for the catalog `/Names` dictionary (merged with any
16
- * `/EmbeddedFiles`). Returns "" when empty. Names are sorted lexically - a name tree requires it. */
17
- finalize(): string;
17
+ * `/EmbeddedFiles`). Returns "" when empty. Names are sorted lexically - a name tree requires it.
18
+ * Takes the object manager only so the names go through its string choke-point and get encrypted
19
+ * with everything else. */
20
+ finalize(om: PDFObjectManager): string;
18
21
  }
19
22
  export {};
@@ -1,5 +1,3 @@
1
- // A PDF literal string escape (backslash first, then the string delimiters).
2
- const esc = (s) => s.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)");
3
1
  const num = (n) => (Number.isFinite(n) ? Number(n.toFixed(2)) : 0);
4
2
  /**
5
3
  * Collects named destinations - the jump targets an internal `Link({ to })` points at, declared with
@@ -19,13 +17,15 @@ export class DestRegistry {
19
17
  return this.dests.size === 0;
20
18
  }
21
19
  /** The `/Dests << /Names [ ... ] >>` fragment for the catalog `/Names` dictionary (merged with any
22
- * `/EmbeddedFiles`). Returns "" when empty. Names are sorted lexically - a name tree requires it. */
23
- finalize() {
20
+ * `/EmbeddedFiles`). Returns "" when empty. Names are sorted lexically - a name tree requires it.
21
+ * Takes the object manager only so the names go through its string choke-point and get encrypted
22
+ * with everything else. */
23
+ finalize(om) {
24
24
  if (this.dests.size === 0)
25
25
  return "";
26
26
  const entries = [...this.dests.keys()].sort().map((name) => {
27
27
  const d = this.dests.get(name);
28
- return `(${esc(name)}) [${d.pageObjNum} 0 R /XYZ null ${num(d.top)} null]`;
28
+ return `${om.pdfString(name)} [${d.pageObjNum} 0 R /XYZ null ${num(d.top)} null]`;
29
29
  });
30
30
  return `/Dests << /Names [ ${entries.join(" ")} ] >>`;
31
31
  }
@@ -1,5 +1,4 @@
1
1
  // A PDF literal string escape (same rule as elsewhere): backslash first, then the string delimiters.
2
- const esc = (s) => s.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)");
3
2
  const num = (n) => Number(n.toFixed(2));
4
3
  /**
5
4
  * Builds the PDF document outline (`/Outlines`) - the viewer's bookmark panel. Entries are collected
@@ -52,7 +51,7 @@ export class OutlineBuilder {
52
51
  // Emit each item dict, wired to its parent, siblings (prev/next) and first/last child.
53
52
  const emit = (nodes, parentNum) => {
54
53
  nodes.forEach((n, i) => {
55
- const parts = [`/Title (${esc(n.title)})`, `/Parent ${parentNum} 0 R`];
54
+ const parts = [`/Title ${om.pdfString(n.title)}`, `/Parent ${parentNum} 0 R`];
56
55
  if (i > 0)
57
56
  parts.push(`/Prev ${objOf.get(nodes[i - 1])} 0 R`);
58
57
  if (i < nodes.length - 1)