@jasy/pdf 1.0.0-alpha.6 → 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 (116) hide show
  1. package/README.md +7 -7
  2. package/dist/api/descriptor.js +26 -1
  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/layout.d.ts +47 -14
  8. package/dist/api/layout.js +47 -11
  9. package/dist/api/structure.d.ts +11 -0
  10. package/dist/api/structure.js +2 -0
  11. package/dist/api/text.d.ts +18 -2
  12. package/dist/api/text.js +15 -0
  13. package/dist/crypto/legacy-handler.d.ts +43 -0
  14. package/dist/crypto/legacy-handler.js +145 -0
  15. package/dist/crypto/security-handler.d.ts +16 -0
  16. package/dist/crypto/security-handler.js +26 -0
  17. package/dist/edit/acroform-reader.d.ts +70 -0
  18. package/dist/edit/acroform-reader.js +126 -0
  19. package/dist/edit/appearance.d.ts +47 -0
  20. package/dist/edit/appearance.js +171 -0
  21. package/dist/edit/document.d.ts +135 -0
  22. package/dist/edit/document.js +649 -0
  23. package/dist/edit/fill.d.ts +48 -0
  24. package/dist/edit/fill.js +395 -0
  25. package/dist/edit/flatten.d.ts +42 -0
  26. package/dist/edit/flatten.js +437 -0
  27. package/dist/edit/index.d.ts +18 -0
  28. package/dist/edit/index.js +16 -0
  29. package/dist/edit/lexer.d.ts +30 -0
  30. package/dist/edit/lexer.js +285 -0
  31. package/dist/edit/objects.d.ts +63 -0
  32. package/dist/edit/objects.js +56 -0
  33. package/dist/edit/writer.d.ts +53 -0
  34. package/dist/edit/writer.js +261 -0
  35. package/dist/elements/container-element.d.ts +10 -1
  36. package/dist/elements/container-element.js +26 -8
  37. package/dist/elements/forms/checkbox-element.d.ts +51 -0
  38. package/dist/elements/forms/checkbox-element.js +48 -0
  39. package/dist/elements/forms/choice-element.d.ts +53 -0
  40. package/dist/elements/forms/choice-element.js +59 -0
  41. package/dist/elements/forms/push-button-element.d.ts +56 -0
  42. package/dist/elements/forms/push-button-element.js +49 -0
  43. package/dist/elements/forms/radio-element.d.ts +52 -0
  44. package/dist/elements/forms/radio-element.js +49 -0
  45. package/dist/elements/forms/signature-element.d.ts +54 -0
  46. package/dist/elements/forms/signature-element.js +47 -0
  47. package/dist/elements/forms/text-field-element.d.ts +61 -0
  48. package/dist/elements/forms/text-field-element.js +54 -0
  49. package/dist/elements/index.d.ts +8 -0
  50. package/dist/elements/index.js +8 -0
  51. package/dist/elements/layout/keep-together-element.d.ts +42 -0
  52. package/dist/elements/layout/keep-together-element.js +85 -0
  53. package/dist/elements/layout/padding-element.d.ts +3 -0
  54. package/dist/elements/layout/padding-element.js +12 -0
  55. package/dist/elements/layout/page-break-element.d.ts +17 -0
  56. package/dist/elements/layout/page-break-element.js +24 -0
  57. package/dist/elements/layout/positioned-element.d.ts +10 -7
  58. package/dist/elements/layout/positioned-element.js +21 -9
  59. package/dist/elements/page-element.d.ts +11 -1
  60. package/dist/elements/page-element.js +41 -16
  61. package/dist/elements/pdf-element.d.ts +28 -0
  62. package/dist/elements/pdf-element.js +32 -0
  63. package/dist/elements/rectangle-element.d.ts +10 -1
  64. package/dist/elements/rectangle-element.js +27 -6
  65. package/dist/elements/row-element.d.ts +9 -1
  66. package/dist/elements/row-element.js +9 -1
  67. package/dist/elements/text-element.d.ts +30 -4
  68. package/dist/elements/text-element.js +40 -17
  69. package/dist/forms/acroform.d.ts +63 -0
  70. package/dist/forms/acroform.js +427 -0
  71. package/dist/forms/appearance.d.ts +58 -0
  72. package/dist/forms/appearance.js +204 -0
  73. package/dist/forms/field.d.ts +164 -0
  74. package/dist/forms/field.js +1 -0
  75. package/dist/forms/pdf.d.ts +19 -0
  76. package/dist/forms/pdf.js +28 -0
  77. package/dist/ir/display-list.d.ts +18 -1
  78. package/dist/layout/fragmentation.d.ts +12 -0
  79. package/dist/layout/fragmentation.js +45 -8
  80. package/dist/renderer/form-field-renderer.d.ts +20 -0
  81. package/dist/renderer/form-field-renderer.js +12 -0
  82. package/dist/renderer/keep-together-renderer.d.ts +10 -0
  83. package/dist/renderer/keep-together-renderer.js +12 -0
  84. package/dist/renderer/page-break-renderer.d.ts +14 -0
  85. package/dist/renderer/page-break-renderer.js +15 -0
  86. package/dist/renderer/page-renderer.js +8 -2
  87. package/dist/renderer/pdf-backend.d.ts +7 -0
  88. package/dist/renderer/pdf-backend.js +48 -6
  89. package/dist/renderer/pdf-document-class.js +3 -0
  90. package/dist/renderer/pdf-document-renderer.js +26 -7
  91. package/dist/renderer/pdf-renderer.js +25 -1
  92. package/dist/renderer/text-renderer.d.ts +1 -1
  93. package/dist/renderer/text-renderer.js +107 -59
  94. package/dist/text/advance.d.ts +34 -0
  95. package/dist/text/advance.js +42 -0
  96. package/dist/text/line-breaker.d.ts +9 -8
  97. package/dist/text/line-breaker.js +28 -21
  98. package/dist/text/line-metrics.d.ts +49 -0
  99. package/dist/text/line-metrics.js +57 -0
  100. package/dist/text/text-decoration.d.ts +50 -0
  101. package/dist/text/text-decoration.js +62 -0
  102. package/dist/text/text-style.d.ts +13 -3
  103. package/dist/text/text-style.js +10 -3
  104. package/dist/utils/afm-parser.d.ts +34 -0
  105. package/dist/utils/afm-parser.js +67 -0
  106. package/dist/utils/dest-registry.d.ts +5 -2
  107. package/dist/utils/dest-registry.js +5 -5
  108. package/dist/utils/font-metrics.d.ts +14 -0
  109. package/dist/utils/outline.js +1 -2
  110. package/dist/utils/pdf-object-manager.d.ts +45 -1
  111. package/dist/utils/pdf-object-manager.js +193 -26
  112. package/dist/utils/struct-tree.js +2 -3
  113. package/dist/utils/ttf-parser.d.ts +53 -0
  114. package/dist/utils/ttf-parser.js +368 -0
  115. package/dist/validators/element-validator.js +7 -3
  116. 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
  *
@@ -35,6 +36,7 @@ export interface TextRun {
35
36
  fontStyle: FontStyle;
36
37
  fontSize: number;
37
38
  color: Color;
39
+ letterSpacing?: number;
38
40
  tag?: StructTag;
39
41
  }
40
42
  /** An axis-aligned rectangle. An absent `fill` or `stroke` means that part is not drawn. */
@@ -144,6 +146,21 @@ export interface Outline {
144
146
  title: string;
145
147
  level: number;
146
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
+ }
147
164
  /**
148
165
  * Pushes an affine transform (a `q` + `cm` in the content stream): everything between this and the
149
166
  * matching `TransformPop` is painted through `matrix`. This is what `Rotated` wraps its child's nodes
@@ -226,4 +243,4 @@ export interface Path {
226
243
  tag?: StructTag;
227
244
  }
228
245
  /** The closed set of primitives the PDF backend knows how to draw. */
229
- 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;
@@ -12,6 +12,10 @@ import { LayoutContext, PDFElement } from "../elements/pdf-element.js";
12
12
  export interface FragmentResult {
13
13
  fitted: PDFElement | null;
14
14
  remainder: PDFElement | null;
15
+ /** The split ended at a FORCED page break (a `PageBreak`), not because the region filled up. The
16
+ * remainder must start a fresh page, and an enclosing flow must stop packing too - so this bubbles
17
+ * up through nested `fragment()` calls. Absent/false for an ordinary height-driven break. */
18
+ forceBreak?: boolean;
15
19
  }
16
20
  /**
17
21
  * An element that knows how to split itself across regions. Width is already finalized
@@ -29,6 +33,13 @@ export declare function isFragmentable(element: PDFElement): element is PDFEleme
29
33
  * logs and clips, `"ignore"` clips silently.
30
34
  */
31
35
  export type OverflowPolicy = "error" | "warn" | "ignore";
36
+ export declare function reportOverflow(child: PDFElement, childHeight: number, maxHeight: number, policy: OverflowPolicy): void;
37
+ /**
38
+ * True if packing `children` will hit a forced cut: a child that IS a break, contains one deeper in its
39
+ * subtree, or asks to break before/after itself. A flow container reports this from `hasForcedBreak` so
40
+ * its parent fragments it (runs `packChildren`) instead of placing it whole and swallowing the cut.
41
+ */
42
+ export declare function childrenForceBreak(children: PDFElement[]): boolean;
32
43
  /**
33
44
  * Packs a vertical stack of children into `maxHeight`, in order. Children are measured
34
45
  * against `width` (unbounded height) and added until one would overflow; that straddling
@@ -46,4 +57,5 @@ export type OverflowPolicy = "error" | "warn" | "ignore";
46
57
  export declare function packChildren(children: PDFElement[], maxHeight: number, width: number, ctx: LayoutContext, gap?: number): {
47
58
  fitted: PDFElement[];
48
59
  remainder: PDFElement[];
60
+ forceBreak: boolean;
49
61
  };
@@ -10,7 +10,7 @@ export function isFragmentable(element) {
10
10
  const canFragment = element.canFragment;
11
11
  return typeof canFragment !== "function" || canFragment.call(element);
12
12
  }
13
- function reportOverflow(child, childHeight, maxHeight, policy) {
13
+ export function reportOverflow(child, childHeight, maxHeight, policy) {
14
14
  if (policy === "ignore")
15
15
  return;
16
16
  const name = child.constructor.name.replace(/Element$/, "");
@@ -21,6 +21,14 @@ function reportOverflow(child, childHeight, maxHeight, policy) {
21
21
  throw new Error(`Layout overflow: ${detail}`);
22
22
  console.warn(`Layout overflow (clipped): ${detail}`);
23
23
  }
24
+ /**
25
+ * True if packing `children` will hit a forced cut: a child that IS a break, contains one deeper in its
26
+ * subtree, or asks to break before/after itself. A flow container reports this from `hasForcedBreak` so
27
+ * its parent fragments it (runs `packChildren`) instead of placing it whole and swallowing the cut.
28
+ */
29
+ export function childrenForceBreak(children) {
30
+ return children.some((child) => child.hasForcedBreak() || child.breaksBefore() || child.breaksAfter());
31
+ }
24
32
  /**
25
33
  * Packs a vertical stack of children into `maxHeight`, in order. Children are measured
26
34
  * against `width` (unbounded height) and added until one would overflow; that straddling
@@ -39,30 +47,61 @@ export function packChildren(children, maxHeight, width, ctx, gap = 0) {
39
47
  const fitted = [];
40
48
  const remainder = [];
41
49
  let usedHeight = 0;
50
+ // Everything from index `from` onward spills to the next region; `broke` says whether the cut was
51
+ // a FORCED page break (so an enclosing flow stops too) rather than the region filling up.
52
+ const spill = (from, broke) => {
53
+ for (let j = from; j < children.length; j++)
54
+ remainder.push(children[j]);
55
+ return { fitted, remainder, forceBreak: broke };
56
+ };
42
57
  for (let i = 0; i < children.length; i++) {
43
58
  const child = children[i];
59
+ // A forced break: consume the marker (it draws nothing) and send everything after it to a fresh
60
+ // page, regardless of how much room is left.
61
+ if (child.isPageBreak())
62
+ return spill(i + 1, true);
63
+ // break-before: this child wants a fresh page. Honour it only if something is already on this region
64
+ // (CSS ignores break-before at the top of a page); that guard also prevents an empty page + a loop.
65
+ if (child.breaksBefore() && fitted.length > 0)
66
+ return spill(i, true);
44
67
  const childHeight = child.calculateLayout(BoxConstraints.loose(width, Infinity), { x: 0, y: 0 }, ctx).height;
45
68
  // A gap precedes every child except the first one placed in this region.
46
69
  const lead = fitted.length > 0 ? gap : 0;
47
- if (usedHeight + lead + childHeight <= maxHeight) {
70
+ // Place whole only if it fits AND holds no forced break: a child that fits by height but contains
71
+ // a `PageBreak` (or a break-before/after) in its subtree must still be fragmented, or the break
72
+ // would be swallowed.
73
+ if (usedHeight + lead + childHeight <= maxHeight && !child.hasForcedBreak()) {
48
74
  fitted.push(child);
49
75
  usedHeight += lead + childHeight;
76
+ // break-after: everything after this child starts a fresh page.
77
+ if (child.breaksAfter())
78
+ return spill(i + 1, true);
50
79
  continue;
51
80
  }
52
- // `child` straddles the boundary. Try to split it; otherwise place/defer it whole.
81
+ // `child` straddles the boundary, or carries a forced break. Split it; otherwise place/defer whole.
53
82
  const remaining = maxHeight - usedHeight - lead;
54
83
  let placedPart = false;
84
+ let childBroke = false;
55
85
  if (isFragmentable(child)) {
56
86
  const split = child.fragment(Math.max(0, remaining), width, ctx);
87
+ childBroke = split.forceBreak ?? false;
57
88
  if (split.fitted) {
58
89
  fitted.push(split.fitted);
90
+ // The child fully fit and triggered no cut: it only reached this path because its subtree
91
+ // FLAGGED a possible break (hasForcedBreak) that turned out to be a no-op here - e.g. a
92
+ // break-before at the top of the region, which CSS ignores. Keep packing the siblings after
93
+ // it instead of spilling them, so break-before does not accidentally push later content away.
94
+ if (!split.remainder && !childBroke) {
95
+ usedHeight += lead + childHeight;
96
+ continue;
97
+ }
59
98
  if (split.remainder)
60
99
  remainder.push(split.remainder);
61
100
  placedPart = true;
62
101
  }
63
102
  }
64
103
  if (!placedPart) {
65
- if (fitted.length === 0) {
104
+ if (fitted.length === 0 && !childBroke) {
66
105
  // Taller than the whole region and unsplittable: force it on (it overflows and is clipped)
67
106
  // so the next region still advances - and surface it per the overflow policy.
68
107
  reportOverflow(child, childHeight, maxHeight, ctx.onOverflow ?? "ignore");
@@ -72,9 +111,7 @@ export function packChildren(children, maxHeight, width, ctx, gap = 0) {
72
111
  remainder.push(child);
73
112
  }
74
113
  }
75
- for (let j = i + 1; j < children.length; j++)
76
- remainder.push(children[j]);
77
- break;
114
+ return spill(i + 1, childBroke);
78
115
  }
79
- return { fitted, remainder };
116
+ return { fitted, remainder, forceBreak: false };
80
117
  }
@@ -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
+ }
@@ -0,0 +1,10 @@
1
+ import { KeepTogetherElement } from "../elements/layout/keep-together-element.js";
2
+ import { PDFObjectManager } from "../utils/pdf-object-manager.js";
3
+ import { IRNode } from "../ir/display-list.js";
4
+ /**
5
+ * A `keepTogether` is layout-transparent: it just renders its child. All of its work happens in the
6
+ * layout pass (`fragment`), never here.
7
+ */
8
+ export declare class KeepTogetherRenderer {
9
+ static render(group: KeepTogetherElement, objectManager: PDFObjectManager): Promise<IRNode[]>;
10
+ }
@@ -0,0 +1,12 @@
1
+ import { RendererRegistry } from "../utils/renderer-registry.js";
2
+ /**
3
+ * A `keepTogether` is layout-transparent: it just renders its child. All of its work happens in the
4
+ * layout pass (`fragment`), never here.
5
+ */
6
+ export class KeepTogetherRenderer {
7
+ static async render(group, objectManager) {
8
+ const { child } = group.getProps();
9
+ const renderer = RendererRegistry.getRenderer(child);
10
+ return renderer ? await renderer(child, objectManager) : [];
11
+ }
12
+ }