@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.
- package/README.md +7 -7
- package/dist/api/descriptor.js +23 -0
- package/dist/api/forms.d.ts +347 -0
- package/dist/api/forms.js +189 -0
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.js +1 -0
- package/dist/api/structure.d.ts +6 -0
- package/dist/api/structure.js +1 -0
- 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 +135 -0
- package/dist/edit/document.js +649 -0
- package/dist/edit/fill.d.ts +48 -0
- package/dist/edit/fill.js +395 -0
- package/dist/edit/flatten.d.ts +42 -0
- package/dist/edit/flatten.js +437 -0
- package/dist/edit/index.d.ts +18 -0
- package/dist/edit/index.js +16 -0
- package/dist/edit/lexer.d.ts +30 -0
- package/dist/edit/lexer.js +285 -0
- package/dist/edit/objects.d.ts +63 -0
- package/dist/edit/objects.js +56 -0
- package/dist/edit/writer.d.ts +53 -0
- package/dist/edit/writer.js +261 -0
- 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/index.d.ts +6 -0
- package/dist/elements/index.js +6 -0
- 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 +17 -1
- package/dist/renderer/form-field-renderer.d.ts +20 -0
- package/dist/renderer/form-field-renderer.js +12 -0
- package/dist/renderer/page-renderer.js +8 -2
- package/dist/renderer/pdf-backend.js +8 -0
- package/dist/renderer/pdf-renderer.js +19 -1
- package/dist/utils/dest-registry.d.ts +5 -2
- package/dist/utils/dest-registry.js +5 -5
- package/dist/utils/outline.js +1 -2
- package/dist/utils/pdf-object-manager.d.ts +18 -0
- package/dist/utils/pdf-object-manager.js +59 -6
- package/dist/utils/struct-tree.js +2 -3
- package/dist/validators/element-validator.js +7 -3
- 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
|
|
84
|
-
: `/A << /S /URI /URI
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/dist/utils/outline.js
CHANGED
|
@@ -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
|
|
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)
|