h17-sspdf 0.1.4 → 0.1.6
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/DOCUMENTATION.md +1490 -0
- package/README.md +2 -2
- package/examples/sources/source-article.json +48 -0
- package/examples/sources/source-blockquote.json +31 -0
- package/examples/sources/source-board-brief.json +258 -0
- package/examples/sources/source-certificate.json +54 -0
- package/examples/sources/source-event-program.json +229 -0
- package/examples/sources/source-financial-report.json +301 -0
- package/examples/sources/source-generic.json +62 -0
- package/examples/sources/source-invoice-table.json +100 -0
- package/examples/sources/source-invoice.json +133 -0
- package/examples/sources/source-magazine.json +48 -0
- package/examples/sources/source-newspaper-frontpage.json +219 -0
- package/examples/sources/source-newspaper-hugopalma-arc.json +303 -0
- package/examples/sources/source-sspdf-story.json +237 -0
- package/examples/sources/source-styled-invoice.json +109 -0
- package/examples/themes/fonts/font-data-example.js +6 -0
- package/examples/themes/table.js +42 -0
- package/examples/themes/theme-ceremony.js +123 -0
- package/examples/themes/theme-corporate.js +195 -0
- package/examples/themes/theme-default.js +556 -0
- package/examples/themes/theme-editorial.js +350 -0
- package/examples/themes/theme-financial.js +226 -0
- package/examples/themes/theme-newsprint.js +253 -0
- package/examples/themes/theme-program.js +141 -0
- package/package.json +4 -1
package/DOCUMENTATION.md
ADDED
|
@@ -0,0 +1,1490 @@
|
|
|
1
|
+
# Documentation
|
|
2
|
+
|
|
3
|
+
How to create themes and source documents for this engine.
|
|
4
|
+
|
|
5
|
+
The engine takes two inputs - a **theme** (styling rules) and a **source** (content) - and produces a PDF. The theme controls every visual decision. The source contains only content and structural intent. The core does math.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Theme
|
|
10
|
+
|
|
11
|
+
A theme is a JS object with this shape:
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
module.exports = {
|
|
15
|
+
name: "My Theme", // used in PDF metadata
|
|
16
|
+
|
|
17
|
+
page: { /* page config */ },
|
|
18
|
+
labels: { /* label styles */ },
|
|
19
|
+
|
|
20
|
+
// optional
|
|
21
|
+
layout: { /* shared layout defaults */ },
|
|
22
|
+
customFonts: [ /* embedded fonts */ ],
|
|
23
|
+
};
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### `page` (required)
|
|
27
|
+
|
|
28
|
+
Controls page geometry, background, and baseline rendering state.
|
|
29
|
+
|
|
30
|
+
```js
|
|
31
|
+
page: {
|
|
32
|
+
// Page geometry
|
|
33
|
+
format: "a4", // only "a4" is supported
|
|
34
|
+
orientation: "portrait", // "portrait" or "landscape"
|
|
35
|
+
unit: "mm", // only "mm" is supported
|
|
36
|
+
compress: true, // PDF compression (default true)
|
|
37
|
+
|
|
38
|
+
// Margins - individual margins override the shared `margin` value
|
|
39
|
+
margin: 15, // fallback for all four sides (mm)
|
|
40
|
+
marginTopMm: 20, // overrides margin for top
|
|
41
|
+
marginBottomMm: 20, // overrides margin for bottom
|
|
42
|
+
marginLeftMm: 18, // overrides margin for left
|
|
43
|
+
marginRightMm: 18, // overrides margin for right
|
|
44
|
+
|
|
45
|
+
// Background
|
|
46
|
+
backgroundColor: [255, 252, 248], // [R, G, B] - painted on every page
|
|
47
|
+
|
|
48
|
+
// Baseline text state - applied after every operation to prevent style leakage.
|
|
49
|
+
// Every property is required.
|
|
50
|
+
defaultText: {
|
|
51
|
+
fontFamily: "helvetica", // jsPDF built-in or custom font family
|
|
52
|
+
fontStyle: "normal", // "normal", "bold", "italic", "bolditalic"
|
|
53
|
+
fontSize: 10, // points
|
|
54
|
+
color: [0, 0, 0], // [R, G, B]
|
|
55
|
+
lineHeight: 1.2, // multiplier
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
// Baseline stroke state - required.
|
|
59
|
+
defaultStroke: {
|
|
60
|
+
color: [0, 0, 0], // [R, G, B]
|
|
61
|
+
lineWidth: 0.2, // mm
|
|
62
|
+
lineCap: "butt", // "butt", "round", "square"
|
|
63
|
+
lineJoin: "miter", // "miter", "round", "bevel"
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
// Baseline fill color - required.
|
|
67
|
+
defaultFillColor: [255, 255, 255], // [R, G, B]
|
|
68
|
+
|
|
69
|
+
// PDF metadata (optional)
|
|
70
|
+
metadata: {
|
|
71
|
+
title: "Document Title",
|
|
72
|
+
subject: "",
|
|
73
|
+
author: "Author Name",
|
|
74
|
+
keywords: "",
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The content area runs from `marginTop` to `pageHeight - marginBottom` vertically, and `marginLeft` to `pageWidth - marginRight` horizontally. On A4 portrait, the page is 210 × 297 mm.
|
|
80
|
+
|
|
81
|
+
### `labels` (required)
|
|
82
|
+
|
|
83
|
+
A flat map of label names to style objects. Every operation in the source references a label by name. If the label doesn't exist in the theme, the engine throws.
|
|
84
|
+
|
|
85
|
+
Label names are arbitrary strings. Use a naming convention that makes sense for your document (e.g., `news.headline`, `resume.title`, `invoice.total`).
|
|
86
|
+
|
|
87
|
+
#### Text label properties
|
|
88
|
+
|
|
89
|
+
Used by `text`, `row`, `bullet`, and `block` operations.
|
|
90
|
+
|
|
91
|
+
| Property | Type | Description |
|
|
92
|
+
|---|---|---|
|
|
93
|
+
| `fontFamily` | string | Font family name (`"helvetica"`, `"courier"`, or custom) |
|
|
94
|
+
| `fontStyle` | string | `"normal"`, `"bold"`, `"italic"`, `"bolditalic"` |
|
|
95
|
+
| `fontSize` | number | Font size in points |
|
|
96
|
+
| `color` | [R,G,B] | Text color |
|
|
97
|
+
| `lineHeight` | number | Line height multiplier (e.g., `1.2`) |
|
|
98
|
+
| `lineHeightMm` | number | Direct line height in mm (overrides fontSize × lineHeight) |
|
|
99
|
+
| `align` | string | `"left"`, `"center"`, `"right"`, `"justify"` |
|
|
100
|
+
| `textTransform` | string | `"upper"` or `"lower"` |
|
|
101
|
+
|
|
102
|
+
#### Spacing
|
|
103
|
+
|
|
104
|
+
Every label can define vertical spacing around its content.
|
|
105
|
+
|
|
106
|
+
| Property | Type | Description |
|
|
107
|
+
|---|---|---|
|
|
108
|
+
| `marginTopMm` | number | Space above in mm |
|
|
109
|
+
| `marginTopPx` | number | Space above in CSS px (converted to mm) |
|
|
110
|
+
| `marginBottomMm` | number | Space below in mm |
|
|
111
|
+
| `marginBottomPx` | number | Space below in CSS px (converted to mm) |
|
|
112
|
+
|
|
113
|
+
#### Padding
|
|
114
|
+
|
|
115
|
+
Padding is inside the content box. Affects text position and background/border sizing.
|
|
116
|
+
|
|
117
|
+
| Property | Type | Description |
|
|
118
|
+
|---|---|---|
|
|
119
|
+
| `paddingMm` | number | Shorthand - all four sides |
|
|
120
|
+
| `paddingPx` | number | Shorthand - all four sides (CSS px) |
|
|
121
|
+
| `paddingTopMm` | number | Top padding in mm |
|
|
122
|
+
| `paddingBottomMm` | number | Bottom padding in mm |
|
|
123
|
+
| `paddingLeftMm` | number | Left padding in mm |
|
|
124
|
+
| `paddingRightMm` | number | Right padding in mm |
|
|
125
|
+
|
|
126
|
+
Each side also accepts a `Px` variant (e.g., `paddingTopPx`). Individual sides override the shorthand.
|
|
127
|
+
|
|
128
|
+
For **row labels**, `paddingLeftMm` on the left label and `paddingRightMm` on the right label offset the text inward from the content edge.
|
|
129
|
+
|
|
130
|
+
#### Container (background & border)
|
|
131
|
+
|
|
132
|
+
When a label defines a background or border, the text is drawn inside a container rect.
|
|
133
|
+
|
|
134
|
+
| Property | Type | Description |
|
|
135
|
+
|---|---|---|
|
|
136
|
+
| `backgroundColor` | [R,G,B] | Fill color behind text |
|
|
137
|
+
| `borderWidthMm` | number | Border stroke width in mm |
|
|
138
|
+
| `borderColor` | [R,G,B] | Border stroke color |
|
|
139
|
+
| `borderRadiusMm` | number | Rounded corners (if jsPDF supports `roundedRect`) |
|
|
140
|
+
|
|
141
|
+
#### Left border accent
|
|
142
|
+
|
|
143
|
+
A vertical bar drawn to the left of the text block.
|
|
144
|
+
|
|
145
|
+
```js
|
|
146
|
+
leftBorder: {
|
|
147
|
+
color: [13, 148, 136], // [R, G, B]
|
|
148
|
+
widthMm: 1.4, // bar width
|
|
149
|
+
gapMm: 2.5, // gap between bar and text
|
|
150
|
+
heightMm: 10, // optional - defaults to text block height
|
|
151
|
+
topOffsetMm: 0, // optional - vertical offset from text top
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
#### Divider label properties
|
|
156
|
+
|
|
157
|
+
Used by `divider` operations. Does not use font properties.
|
|
158
|
+
|
|
159
|
+
| Property | Type | Description |
|
|
160
|
+
|---|---|---|
|
|
161
|
+
| `color` | [R,G,B] | Line color |
|
|
162
|
+
| `lineWidth` | number | Line thickness in mm |
|
|
163
|
+
| `opacity` | number | 0-1 stroke opacity |
|
|
164
|
+
| `dashPattern` | number[] | Dash pattern (e.g., `[2, 1]`) |
|
|
165
|
+
| `marginTopMm` | number | Space above |
|
|
166
|
+
| `marginBottomMm` | number | Space below |
|
|
167
|
+
|
|
168
|
+
(Also accepts `Px` variants for margins.)
|
|
169
|
+
|
|
170
|
+
#### Bullet marker label properties
|
|
171
|
+
|
|
172
|
+
The marker label controls the bullet character's appearance.
|
|
173
|
+
|
|
174
|
+
| Property | Type | Description |
|
|
175
|
+
|---|---|---|
|
|
176
|
+
| `fontFamily` | string | Marker font |
|
|
177
|
+
| `fontStyle` | string | Marker font style |
|
|
178
|
+
| `fontSize` | number | Marker font size in points |
|
|
179
|
+
| `color` | [R,G,B] | Marker color |
|
|
180
|
+
| `lineHeight` | number | Marker line height multiplier |
|
|
181
|
+
| `marker` | string | The marker character (e.g., `"-"`) |
|
|
182
|
+
|
|
183
|
+
#### Table label properties
|
|
184
|
+
|
|
185
|
+
Used by `table` operations. The `label` controls data cell styling, `headerLabel` controls header cell styling.
|
|
186
|
+
|
|
187
|
+
| Property | Type | Description |
|
|
188
|
+
|---|---|---|
|
|
189
|
+
| `fontFamily` | string | Cell text font |
|
|
190
|
+
| `fontStyle` | string | Cell text font style |
|
|
191
|
+
| `fontSize` | number | Cell text font size in points |
|
|
192
|
+
| `color` | [R,G,B] | Cell text color |
|
|
193
|
+
| `lineHeight` | number | Cell text line height multiplier |
|
|
194
|
+
| `cellPaddingMm` | number | Padding inside each cell in mm |
|
|
195
|
+
| `backgroundColor` | [R,G,B] | Cell/header background color (even rows use this, odd rows use `altRowColor`) |
|
|
196
|
+
| `altRowColor` | [R,G,B] | Background color for odd data rows (alternating row shading) |
|
|
197
|
+
| `borderColor` | [R,G,B] | Default border color for all edges |
|
|
198
|
+
| `borderTopMm` | number | Top border width in mm |
|
|
199
|
+
| `borderBottomMm` | number | Bottom border width in mm |
|
|
200
|
+
| `borderLeftMm` | number | Left border width in mm |
|
|
201
|
+
| `borderRightMm` | number | Right border width in mm |
|
|
202
|
+
| `borderTopColor` | [R,G,B] | Override border color for top edge |
|
|
203
|
+
| `borderBottomColor` | [R,G,B] | Override border color for bottom edge |
|
|
204
|
+
| `borderLeftColor` | [R,G,B] | Override border color for left edge |
|
|
205
|
+
| `borderRightColor` | [R,G,B] | Override border color for right edge |
|
|
206
|
+
|
|
207
|
+
The header label typically sets `backgroundColor` for the header row background and `color` for white text on dark backgrounds.
|
|
208
|
+
|
|
209
|
+
#### Spacer label properties
|
|
210
|
+
|
|
211
|
+
Used by `spacer` operations that reference a label.
|
|
212
|
+
|
|
213
|
+
| Property | Type | Description |
|
|
214
|
+
|---|---|---|
|
|
215
|
+
| `spaceMm` | number | Vertical space in mm |
|
|
216
|
+
| `spacePx` | number | Vertical space in CSS px |
|
|
217
|
+
|
|
218
|
+
### `layout` (optional)
|
|
219
|
+
|
|
220
|
+
Shared layout defaults read by operation handlers.
|
|
221
|
+
|
|
222
|
+
```js
|
|
223
|
+
layout: {
|
|
224
|
+
bulletIndentMm: 4.5, // text indent after bullet marker (default: 4)
|
|
225
|
+
}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### `customFonts` (optional)
|
|
229
|
+
|
|
230
|
+
Embed TTF fonts as base64 for use in labels.
|
|
231
|
+
|
|
232
|
+
```js
|
|
233
|
+
customFonts: [
|
|
234
|
+
{
|
|
235
|
+
family: "Inter",
|
|
236
|
+
faces: [
|
|
237
|
+
{ style: "normal", fileName: "Inter-Regular.ttf", data: "<base64 string>" },
|
|
238
|
+
{ style: "bold", fileName: "Inter-Bold.ttf", data: "<base64 string>" },
|
|
239
|
+
],
|
|
240
|
+
},
|
|
241
|
+
],
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
After registration, use `fontFamily: "Inter"` in any label.
|
|
245
|
+
|
|
246
|
+
### Label example
|
|
247
|
+
|
|
248
|
+
```js
|
|
249
|
+
labels: {
|
|
250
|
+
"doc.title": {
|
|
251
|
+
fontFamily: "helvetica",
|
|
252
|
+
fontStyle: "bold",
|
|
253
|
+
fontSize: 22,
|
|
254
|
+
color: [30, 30, 30],
|
|
255
|
+
lineHeight: 1.2,
|
|
256
|
+
align: "center",
|
|
257
|
+
marginBottomPx: 8,
|
|
258
|
+
},
|
|
259
|
+
"doc.divider": {
|
|
260
|
+
color: [200, 200, 200],
|
|
261
|
+
lineWidth: 0.3,
|
|
262
|
+
marginBottomPx: 6,
|
|
263
|
+
},
|
|
264
|
+
"doc.body": {
|
|
265
|
+
fontFamily: "helvetica",
|
|
266
|
+
fontStyle: "normal",
|
|
267
|
+
fontSize: 10,
|
|
268
|
+
color: [50, 50, 50],
|
|
269
|
+
lineHeight: 1.4,
|
|
270
|
+
align: "justify",
|
|
271
|
+
marginBottomPx: 4,
|
|
272
|
+
},
|
|
273
|
+
}
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
---
|
|
277
|
+
|
|
278
|
+
## Source (JSON)
|
|
279
|
+
|
|
280
|
+
The source is a JSON object that contains only content and structure. No colors, no sizes, no positions - those all come from the theme via labels.
|
|
281
|
+
|
|
282
|
+
### Root format
|
|
283
|
+
|
|
284
|
+
The source can take several forms:
|
|
285
|
+
|
|
286
|
+
```json
|
|
287
|
+
{ "operations": [ ... ] }
|
|
288
|
+
```
|
|
289
|
+
```json
|
|
290
|
+
{ "sections": [ ... ] }
|
|
291
|
+
```
|
|
292
|
+
```json
|
|
293
|
+
{ "content": [ ... ] }
|
|
294
|
+
```
|
|
295
|
+
```json
|
|
296
|
+
{ "items": [ ... ] }
|
|
297
|
+
```
|
|
298
|
+
```json
|
|
299
|
+
{ "children": [ ... ] }
|
|
300
|
+
```
|
|
301
|
+
```json
|
|
302
|
+
[ ... ]
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
All are equivalent - the engine normalizes them into a flat operation list. `sections`, `content`, `items`, and `children` are interchangeable wrapper keys.
|
|
306
|
+
|
|
307
|
+
### `pageTemplates` (optional)
|
|
308
|
+
|
|
309
|
+
Defined at the source root, alongside the operation array. Renders repeating headers/footers on every page.
|
|
310
|
+
|
|
311
|
+
```json
|
|
312
|
+
{
|
|
313
|
+
"pageTemplates": {
|
|
314
|
+
"header": [ /* operations */ ],
|
|
315
|
+
"footer": [ /* operations */ ],
|
|
316
|
+
"headerHeightMm": 12,
|
|
317
|
+
"footerHeightMm": 10,
|
|
318
|
+
"headerStartMm": 5,
|
|
319
|
+
"footerStartMm": 280,
|
|
320
|
+
"headerBypassMargins": true,
|
|
321
|
+
"footerBypassMargins": true
|
|
322
|
+
},
|
|
323
|
+
"sections": [ ... ]
|
|
324
|
+
}
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
- `headerHeightMm` / `footerHeightMm` - reserves space in the content area so body text doesn't overlap. If omitted and operations exist, defaults to 12mm (header) or 10mm (footer).
|
|
328
|
+
- `headerStartMm` / `footerStartMm` - Y position where the template starts rendering. Footer defaults to `pageHeight - footerHeightMm`.
|
|
329
|
+
- `headerBypassMargins` / `footerBypassMargins` - if true (default), template operations can use the full page width instead of the content area.
|
|
330
|
+
|
|
331
|
+
The `{{page}}` token in any text value resolves to the current page number.
|
|
332
|
+
|
|
333
|
+
---
|
|
334
|
+
|
|
335
|
+
### Operation types
|
|
336
|
+
|
|
337
|
+
#### `text`
|
|
338
|
+
|
|
339
|
+
Renders wrapped text.
|
|
340
|
+
|
|
341
|
+
```json
|
|
342
|
+
{ "type": "text", "label": "doc.body", "text": "Paragraph content here." }
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
| Field | Required | Type | Description |
|
|
346
|
+
|---|---|---|---|
|
|
347
|
+
| `label` | yes | string | Theme label for styling |
|
|
348
|
+
| `text` | yes | string or string[] | Text content. Array expands to multiple text operations sharing the same label. |
|
|
349
|
+
| `keepWithNext` | no | number or true | Keep this + next N operations together on the same page. `true` = 1. |
|
|
350
|
+
| `align` | no | string | Overrides label's `align` |
|
|
351
|
+
| `wrap` | no | boolean | Set `false` to disable wrapping (default true) |
|
|
352
|
+
| `advance` | no | boolean | Set `false` to not move cursor after drawing (default true) |
|
|
353
|
+
|
|
354
|
+
When `text` is an array:
|
|
355
|
+
```json
|
|
356
|
+
{
|
|
357
|
+
"type": "text",
|
|
358
|
+
"label": "doc.body",
|
|
359
|
+
"text": [
|
|
360
|
+
"First paragraph.",
|
|
361
|
+
"Second paragraph.",
|
|
362
|
+
"Third paragraph."
|
|
363
|
+
]
|
|
364
|
+
}
|
|
365
|
+
```
|
|
366
|
+
Each string becomes its own text operation with the same label.
|
|
367
|
+
|
|
368
|
+
#### `row`
|
|
369
|
+
|
|
370
|
+
Renders two text values on the same line - one left-aligned, one right-aligned.
|
|
371
|
+
|
|
372
|
+
```json
|
|
373
|
+
{
|
|
374
|
+
"type": "row",
|
|
375
|
+
"leftLabel": "meta.left",
|
|
376
|
+
"rightLabel": "meta.right",
|
|
377
|
+
"leftText": "Author Name",
|
|
378
|
+
"rightText": "March 2026"
|
|
379
|
+
}
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
| Field | Required | Type | Description |
|
|
383
|
+
|---|---|---|---|
|
|
384
|
+
| `leftLabel` | yes | string | Theme label for left text |
|
|
385
|
+
| `rightLabel` | yes | string | Theme label for right text |
|
|
386
|
+
| `leftText` | yes | string | Left-aligned text |
|
|
387
|
+
| `rightText` | yes | string | Right-aligned text |
|
|
388
|
+
|
|
389
|
+
The cursor delta uses the taller of the two sides: `max(leftMarginTop, rightMarginTop) + max(leftLineHeight, rightLineHeight) + max(leftMarginBottom, rightMarginBottom)`.
|
|
390
|
+
|
|
391
|
+
#### `bullet`
|
|
392
|
+
|
|
393
|
+
Renders a marker character followed by wrapped text.
|
|
394
|
+
|
|
395
|
+
```json
|
|
396
|
+
{ "type": "bullet", "label": "doc.body", "text": "A bullet point." }
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
| Field | Required | Type | Description |
|
|
400
|
+
|---|---|---|---|
|
|
401
|
+
| `label` | yes | string | Theme label for the bullet text |
|
|
402
|
+
| `text` / `items` / `bullets` | yes | string or string[] | Content. Array expands to multiple bullets. |
|
|
403
|
+
| `markerLabel` | no | string | Theme label for the marker (default: `"bullet.marker"`) |
|
|
404
|
+
| `marker` | no | string | Override marker character |
|
|
405
|
+
| `textIndentMm` | no | number | Override indent between marker and text |
|
|
406
|
+
| `keepWithNext` | no | number or true | Keep with next operations |
|
|
407
|
+
|
|
408
|
+
Array form:
|
|
409
|
+
```json
|
|
410
|
+
{
|
|
411
|
+
"type": "bullet",
|
|
412
|
+
"label": "doc.body",
|
|
413
|
+
"markerLabel": "doc.marker",
|
|
414
|
+
"bullets": [
|
|
415
|
+
"First point.",
|
|
416
|
+
"Second point.",
|
|
417
|
+
"Third point."
|
|
418
|
+
]
|
|
419
|
+
}
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
#### `divider`
|
|
423
|
+
|
|
424
|
+
Renders a horizontal line.
|
|
425
|
+
|
|
426
|
+
```json
|
|
427
|
+
{ "type": "divider", "label": "doc.divider" }
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
| Field | Required | Type | Description |
|
|
431
|
+
|---|---|---|---|
|
|
432
|
+
| `label` | yes | string | Theme label (must define `lineWidth` and `color`) |
|
|
433
|
+
|
|
434
|
+
#### `table`
|
|
435
|
+
|
|
436
|
+
Renders a data table with optional header row, per-column alignment, alternating row colors, and per-edge borders. Pure vector rendering, no images.
|
|
437
|
+
|
|
438
|
+
```json
|
|
439
|
+
{
|
|
440
|
+
"type": "table",
|
|
441
|
+
"label": "invoice.table.cell",
|
|
442
|
+
"headerLabel": "invoice.table.header",
|
|
443
|
+
"columns": [
|
|
444
|
+
{ "header": "Description", "width": "45%", "align": "left" },
|
|
445
|
+
{ "header": "Qty", "width": "10%", "align": "right" },
|
|
446
|
+
{ "header": "Unit Price", "width": "20%", "align": "right" },
|
|
447
|
+
{ "header": "Amount", "width": "25%", "align": "right" }
|
|
448
|
+
],
|
|
449
|
+
"rows": [
|
|
450
|
+
["Automation Workflow", "1", "$3,200.00", "$3,200.00"],
|
|
451
|
+
["Monitoring Dashboard", "1", "$1,400.00", "$1,400.00"]
|
|
452
|
+
]
|
|
453
|
+
}
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
| Field | Required | Type | Description |
|
|
457
|
+
|---|---|---|---|
|
|
458
|
+
| `label` | yes | string | Theme label for data cells |
|
|
459
|
+
| `headerLabel` | no | string | Theme label for the header row. If omitted, no header is rendered. |
|
|
460
|
+
| `columns` | yes | array | Column definitions (see below) |
|
|
461
|
+
| `rows` | yes | string[][] | Data rows, each an array of cell strings |
|
|
462
|
+
| `xMm` | no | number | Left edge in mm (default: content left margin) |
|
|
463
|
+
| `maxWidthMm` | no | number | Total table width in mm (default: content area width) |
|
|
464
|
+
| `altRowColor` | no | [R,G,B] | Override the label's `altRowColor` for this table |
|
|
465
|
+
| `cellPaddingMm` | no | number | Override the label's `cellPaddingMm` for this table |
|
|
466
|
+
| `borderColor` | no | [R,G,B] | Override the label's `borderColor` for this table |
|
|
467
|
+
| `borderTopMm` | no | number | Override the label's `borderTopMm` for this table |
|
|
468
|
+
| `borderBottomMm` | no | number | Override the label's `borderBottomMm` for this table |
|
|
469
|
+
| `borderLeftMm` | no | number | Override the label's `borderLeftMm` for this table |
|
|
470
|
+
| `borderRightMm` | no | number | Override the label's `borderRightMm` for this table |
|
|
471
|
+
|
|
472
|
+
**Column definition:**
|
|
473
|
+
|
|
474
|
+
| Field | Required | Type | Description |
|
|
475
|
+
|---|---|---|---|
|
|
476
|
+
| `header` | no | string | Header text for this column (used when `headerLabel` is set) |
|
|
477
|
+
| `width` | no | string or number | `"30%"` (percentage), `35` (fixed mm), or omitted (auto-divide remaining space) |
|
|
478
|
+
| `align` | no | string | `"left"`, `"right"`, or `"center"` (default: `"left"`) |
|
|
479
|
+
|
|
480
|
+
**Page breaks:** When a table spans multiple pages, the header row is automatically re-drawn at the top of each new page.
|
|
481
|
+
|
|
482
|
+
**Style cascade:** Engine defaults, then theme label, then source-level overrides. The source operation can override `altRowColor`, `cellPaddingMm`, and all border properties directly.
|
|
483
|
+
|
|
484
|
+
#### `spacer`
|
|
485
|
+
|
|
486
|
+
Adds vertical space without drawing anything.
|
|
487
|
+
|
|
488
|
+
```json
|
|
489
|
+
{ "type": "spacer", "mm": 10 }
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
Three forms (use exactly one):
|
|
493
|
+
|
|
494
|
+
| Field | Type | Description |
|
|
495
|
+
|---|---|---|
|
|
496
|
+
| `mm` | number | Fixed space in mm |
|
|
497
|
+
| `px` | number | Fixed space in CSS px |
|
|
498
|
+
| `label` | string | Theme label with `spaceMm` or `spacePx` |
|
|
499
|
+
|
|
500
|
+
#### `hiddenText`
|
|
501
|
+
|
|
502
|
+
Renders text in the background color so it's invisible but present in the PDF text layer (for ATS keyword injection).
|
|
503
|
+
|
|
504
|
+
```json
|
|
505
|
+
{ "type": "hiddenText", "label": "doc.hidden", "text": "keywords for ATS parsing" }
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
Does **not** move the cursor.
|
|
509
|
+
|
|
510
|
+
#### `quote`
|
|
511
|
+
|
|
512
|
+
Shorthand for a blockquote - normalizes into a `block` with `keepTogether: true` containing the quote text and an optional attribution line.
|
|
513
|
+
|
|
514
|
+
```json
|
|
515
|
+
{
|
|
516
|
+
"type": "quote",
|
|
517
|
+
"label": "doc.pullquote",
|
|
518
|
+
"text": "The quoted text goes here.",
|
|
519
|
+
"attribution": "- Author Name"
|
|
520
|
+
}
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
| Field | Required | Type | Description |
|
|
524
|
+
|---|---|---|---|
|
|
525
|
+
| `label` | yes | string | Theme label for the quote text (also used for block container) |
|
|
526
|
+
| `text` / `content` | yes | string | The quoted text |
|
|
527
|
+
| `attribution` / `author` | no | string | Attribution line |
|
|
528
|
+
| `attributionLabel` | no | string | Label for attribution (default: `label + ".attribution"`) |
|
|
529
|
+
|
|
530
|
+
The block container uses the quote's `label` - if that label has `backgroundColor`, the background wraps the entire quote + attribution.
|
|
531
|
+
|
|
532
|
+
#### `block`
|
|
533
|
+
|
|
534
|
+
Groups child operations. If the label has `backgroundColor` or `borderWidthMm`, a container rect is drawn behind the children.
|
|
535
|
+
|
|
536
|
+
```json
|
|
537
|
+
{
|
|
538
|
+
"type": "block",
|
|
539
|
+
"label": "doc.callout",
|
|
540
|
+
"keepTogether": true,
|
|
541
|
+
"children": [
|
|
542
|
+
{ "type": "text", "label": "doc.body", "text": "Inside the block." },
|
|
543
|
+
{ "type": "text", "label": "doc.body", "text": "Also inside." }
|
|
544
|
+
]
|
|
545
|
+
}
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
| Field | Required | Type | Description |
|
|
549
|
+
|---|---|---|---|
|
|
550
|
+
| `label` | no | string | Theme label for container styling (background, border) |
|
|
551
|
+
| `children` / `content` / `items` / `sections` | yes | array | Child operations |
|
|
552
|
+
| `keepTogether` | no | boolean | If true, all children move to next page if they don't fit (default true for blocks with containers) |
|
|
553
|
+
| `spaceAfterMm` | no | number | Extra space after the block |
|
|
554
|
+
| `spaceAfterPx` | no | number | Extra space after the block (CSS px) |
|
|
555
|
+
| `spaceAfterLabel` | no | string | Theme label with `spaceMm`/`spacePx` for space after |
|
|
556
|
+
|
|
557
|
+
Inside a block with a container, child label styles have their `backgroundColor`, `borderWidthMm`, `borderColor`, `borderRadiusMm`, and `leftBorder` stripped so they don't draw their own containers.
|
|
558
|
+
|
|
559
|
+
#### `section`
|
|
560
|
+
|
|
561
|
+
Identical to `block` but defaults to `keepTogether: false`. Use sections to group related content without forcing it onto one page.
|
|
562
|
+
|
|
563
|
+
```json
|
|
564
|
+
{
|
|
565
|
+
"type": "section",
|
|
566
|
+
"content": [
|
|
567
|
+
{ "type": "text", "label": "doc.heading", "text": "Section Title", "keepWithNext": 3 },
|
|
568
|
+
{ "type": "text", "label": "doc.body", "text": "Section body text." }
|
|
569
|
+
]
|
|
570
|
+
}
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
#### `group`
|
|
574
|
+
|
|
575
|
+
Alias for `block`.
|
|
576
|
+
|
|
577
|
+
### Inferred text operations
|
|
578
|
+
|
|
579
|
+
If a node has `label` and `text` (or `value`) but no `type`, it's treated as text:
|
|
580
|
+
|
|
581
|
+
```json
|
|
582
|
+
{ "label": "doc.body", "text": "This works too." }
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
Array values expand to multiple text operations:
|
|
586
|
+
```json
|
|
587
|
+
{ "label": "doc.body", "text": ["Paragraph one.", "Paragraph two."] }
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
---
|
|
591
|
+
|
|
592
|
+
## Cursor math
|
|
593
|
+
|
|
594
|
+
The engine positions content using a cursor that starts at `marginTopMm` and moves downward. Each operation advances the cursor by a deterministic amount:
|
|
595
|
+
|
|
596
|
+
| Operation | Cursor delta |
|
|
597
|
+
|---|---|
|
|
598
|
+
| `text` | marginTop + paddingTop + (lineCount × lineHeightMm) + paddingBottom + marginBottom |
|
|
599
|
+
| `row` | max(marginTops) + max(lineHeights) + max(marginBottoms) |
|
|
600
|
+
| `bullet` | marginTop + (lineCount × lineHeightMm) + marginBottom |
|
|
601
|
+
| `divider` | marginTop + lineWidth + marginBottom |
|
|
602
|
+
| `spacer` | the specified mm/px value |
|
|
603
|
+
| `hiddenText` | 0 |
|
|
604
|
+
| `table` | marginTop + headerRowHeight + sum(dataRowHeights) + marginBottom |
|
|
605
|
+
| `block` | sum of children deltas (+ spaceAfter if defined) |
|
|
606
|
+
|
|
607
|
+
Where:
|
|
608
|
+
- `lineHeightMm = (fontSize × 25.4/72) × lineHeight` (font points → mm × multiplier)
|
|
609
|
+
- `marginBottomPx` converts via `× 25.4/96`
|
|
610
|
+
- `lineCount` is determined by jsPDF's text wrapping at the available width
|
|
611
|
+
|
|
612
|
+
When `cursorY + requiredHeight > contentBottomY`, a page break occurs and the cursor resets to `contentTopY`.
|
|
613
|
+
|
|
614
|
+
---
|
|
615
|
+
|
|
616
|
+
## Minimal working example
|
|
617
|
+
|
|
618
|
+
**Theme:**
|
|
619
|
+
```js
|
|
620
|
+
module.exports = {
|
|
621
|
+
name: "Minimal",
|
|
622
|
+
page: {
|
|
623
|
+
format: "a4",
|
|
624
|
+
orientation: "portrait",
|
|
625
|
+
unit: "mm",
|
|
626
|
+
marginTopMm: 20,
|
|
627
|
+
marginBottomMm: 20,
|
|
628
|
+
marginLeftMm: 20,
|
|
629
|
+
marginRightMm: 20,
|
|
630
|
+
backgroundColor: [255, 255, 255],
|
|
631
|
+
defaultText: {
|
|
632
|
+
fontFamily: "helvetica",
|
|
633
|
+
fontStyle: "normal",
|
|
634
|
+
fontSize: 10,
|
|
635
|
+
color: [0, 0, 0],
|
|
636
|
+
lineHeight: 1.2,
|
|
637
|
+
},
|
|
638
|
+
defaultStroke: {
|
|
639
|
+
color: [0, 0, 0],
|
|
640
|
+
lineWidth: 0.2,
|
|
641
|
+
lineCap: "butt",
|
|
642
|
+
lineJoin: "miter",
|
|
643
|
+
},
|
|
644
|
+
defaultFillColor: [255, 255, 255],
|
|
645
|
+
},
|
|
646
|
+
labels: {
|
|
647
|
+
"title": {
|
|
648
|
+
fontFamily: "helvetica",
|
|
649
|
+
fontStyle: "bold",
|
|
650
|
+
fontSize: 18,
|
|
651
|
+
color: [0, 0, 0],
|
|
652
|
+
lineHeight: 1.2,
|
|
653
|
+
align: "center",
|
|
654
|
+
marginBottomPx: 8,
|
|
655
|
+
},
|
|
656
|
+
"body": {
|
|
657
|
+
fontFamily: "helvetica",
|
|
658
|
+
fontStyle: "normal",
|
|
659
|
+
fontSize: 10,
|
|
660
|
+
color: [50, 50, 50],
|
|
661
|
+
lineHeight: 1.4,
|
|
662
|
+
marginBottomPx: 4,
|
|
663
|
+
},
|
|
664
|
+
"rule": {
|
|
665
|
+
color: [200, 200, 200],
|
|
666
|
+
lineWidth: 0.3,
|
|
667
|
+
marginBottomPx: 6,
|
|
668
|
+
},
|
|
669
|
+
},
|
|
670
|
+
};
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
**Source:**
|
|
674
|
+
```json
|
|
675
|
+
{
|
|
676
|
+
"operations": [
|
|
677
|
+
{ "type": "text", "label": "title", "text": "Hello World" },
|
|
678
|
+
{ "type": "divider", "label": "rule" },
|
|
679
|
+
{
|
|
680
|
+
"type": "text",
|
|
681
|
+
"label": "body",
|
|
682
|
+
"text": [
|
|
683
|
+
"First paragraph of content.",
|
|
684
|
+
"Second paragraph of content."
|
|
685
|
+
]
|
|
686
|
+
}
|
|
687
|
+
]
|
|
688
|
+
}
|
|
689
|
+
```
|
|
690
|
+
|
|
691
|
+
**Render:**
|
|
692
|
+
```js
|
|
693
|
+
const { renderDocument } = require("./index");
|
|
694
|
+
const theme = require("./my-theme");
|
|
695
|
+
const source = require("./my-source.json");
|
|
696
|
+
|
|
697
|
+
renderDocument({ source, theme, outputPath: "output.pdf" });
|
|
698
|
+
```
|
|
699
|
+
|
|
700
|
+
---
|
|
701
|
+
|
|
702
|
+
## Tutorial: Building a layout from scratch
|
|
703
|
+
|
|
704
|
+
This walks through creating a conference talk summary - a document with a title, speaker info, a divider, body paragraphs, key takeaways as bullets, and a pullquote. Then it adds a footer on every page.
|
|
705
|
+
|
|
706
|
+
### Step 1: Decide what your document needs
|
|
707
|
+
|
|
708
|
+
Sketch the elements:
|
|
709
|
+
|
|
710
|
+
```
|
|
711
|
+
TALK TITLE (big, centered)
|
|
712
|
+
Speaker Name March 2026
|
|
713
|
+
──────────────────────────────────────────
|
|
714
|
+
Body paragraph
|
|
715
|
+
Body paragraph
|
|
716
|
+
Body paragraph
|
|
717
|
+
|
|
718
|
+
Key Takeaways
|
|
719
|
+
- Bullet one
|
|
720
|
+
- Bullet two
|
|
721
|
+
|
|
722
|
+
┌──────────────────────────────────────┐
|
|
723
|
+
│ "A compelling quote from the talk." │
|
|
724
|
+
│ - Speaker Name │
|
|
725
|
+
└──────────────────────────────────────┘
|
|
726
|
+
|
|
727
|
+
──────────────────────────────────────────
|
|
728
|
+
Conference Name Page 1
|
|
729
|
+
```
|
|
730
|
+
|
|
731
|
+
Each distinct visual element needs its own label. Count them:
|
|
732
|
+
|
|
733
|
+
1. Title text → `talk.title`
|
|
734
|
+
2. Speaker name (left) → `talk.meta.left`
|
|
735
|
+
3. Date (right) → `talk.meta.right`
|
|
736
|
+
4. Section divider → `talk.rule`
|
|
737
|
+
5. Body paragraphs → `talk.body`
|
|
738
|
+
6. Section heading → `talk.heading`
|
|
739
|
+
7. Bullet text → `talk.body` (reuse - same style)
|
|
740
|
+
8. Bullet marker → `talk.marker`
|
|
741
|
+
9. Quote text → `talk.quote`
|
|
742
|
+
10. Quote attribution → `talk.quote.attribution`
|
|
743
|
+
11. Footer divider → `talk.footer.rule`
|
|
744
|
+
12. Footer left text → `talk.footer.left`
|
|
745
|
+
13. Footer right text → `talk.footer.right`
|
|
746
|
+
|
|
747
|
+
### Step 2: Build the theme - page first
|
|
748
|
+
|
|
749
|
+
Start with the page config. Pick margins, background, and defaults.
|
|
750
|
+
|
|
751
|
+
```js
|
|
752
|
+
module.exports = {
|
|
753
|
+
name: "Conference Talk Summary",
|
|
754
|
+
|
|
755
|
+
page: {
|
|
756
|
+
format: "a4",
|
|
757
|
+
orientation: "portrait",
|
|
758
|
+
unit: "mm",
|
|
759
|
+
marginTopMm: 22,
|
|
760
|
+
marginBottomMm: 22,
|
|
761
|
+
marginLeftMm: 24,
|
|
762
|
+
marginRightMm: 24,
|
|
763
|
+
backgroundColor: [255, 255, 255],
|
|
764
|
+
defaultText: {
|
|
765
|
+
fontFamily: "helvetica",
|
|
766
|
+
fontStyle: "normal",
|
|
767
|
+
fontSize: 10,
|
|
768
|
+
color: [0, 0, 0],
|
|
769
|
+
lineHeight: 1.2,
|
|
770
|
+
},
|
|
771
|
+
defaultStroke: {
|
|
772
|
+
color: [0, 0, 0],
|
|
773
|
+
lineWidth: 0.2,
|
|
774
|
+
lineCap: "butt",
|
|
775
|
+
lineJoin: "miter",
|
|
776
|
+
},
|
|
777
|
+
defaultFillColor: [255, 255, 255],
|
|
778
|
+
},
|
|
779
|
+
```
|
|
780
|
+
|
|
781
|
+
### Step 3: Build the theme - define every label
|
|
782
|
+
|
|
783
|
+
Each label is a complete style definition. The core reads exactly what you write - no fallbacks, no inheritance between labels. If a label needs `fontFamily`, you write `fontFamily`.
|
|
784
|
+
|
|
785
|
+
```js
|
|
786
|
+
labels: {
|
|
787
|
+
// ── Title ──
|
|
788
|
+
"talk.title": {
|
|
789
|
+
fontFamily: "helvetica",
|
|
790
|
+
fontStyle: "bold",
|
|
791
|
+
fontSize: 20,
|
|
792
|
+
color: [20, 20, 20],
|
|
793
|
+
lineHeight: 1.2,
|
|
794
|
+
align: "center",
|
|
795
|
+
marginBottomPx: 6,
|
|
796
|
+
},
|
|
797
|
+
|
|
798
|
+
// ── Speaker row ──
|
|
799
|
+
"talk.meta.left": {
|
|
800
|
+
fontFamily: "helvetica",
|
|
801
|
+
fontStyle: "normal",
|
|
802
|
+
fontSize: 9,
|
|
803
|
+
color: [100, 100, 100],
|
|
804
|
+
lineHeight: 1.2,
|
|
805
|
+
marginBottomPx: 4,
|
|
806
|
+
},
|
|
807
|
+
"talk.meta.right": {
|
|
808
|
+
fontFamily: "helvetica",
|
|
809
|
+
fontStyle: "normal",
|
|
810
|
+
fontSize: 9,
|
|
811
|
+
color: [100, 100, 100],
|
|
812
|
+
lineHeight: 1.2,
|
|
813
|
+
marginBottomPx: 4,
|
|
814
|
+
},
|
|
815
|
+
|
|
816
|
+
// ── Rules ──
|
|
817
|
+
"talk.rule": {
|
|
818
|
+
color: [180, 180, 180],
|
|
819
|
+
lineWidth: 0.3,
|
|
820
|
+
marginBottomPx: 8,
|
|
821
|
+
},
|
|
822
|
+
|
|
823
|
+
// ── Body + bullets share the same style ──
|
|
824
|
+
"talk.body": {
|
|
825
|
+
fontFamily: "helvetica",
|
|
826
|
+
fontStyle: "normal",
|
|
827
|
+
fontSize: 10,
|
|
828
|
+
color: [40, 40, 40],
|
|
829
|
+
lineHeight: 1.45,
|
|
830
|
+
align: "justify",
|
|
831
|
+
marginBottomPx: 5,
|
|
832
|
+
},
|
|
833
|
+
|
|
834
|
+
// ── Section heading ──
|
|
835
|
+
"talk.heading": {
|
|
836
|
+
fontFamily: "helvetica",
|
|
837
|
+
fontStyle: "bold",
|
|
838
|
+
fontSize: 12,
|
|
839
|
+
color: [20, 20, 20],
|
|
840
|
+
lineHeight: 1.2,
|
|
841
|
+
marginTopPx: 8,
|
|
842
|
+
marginBottomPx: 4,
|
|
843
|
+
},
|
|
844
|
+
|
|
845
|
+
// ── Bullet marker ──
|
|
846
|
+
"talk.marker": {
|
|
847
|
+
fontFamily: "helvetica",
|
|
848
|
+
fontStyle: "normal",
|
|
849
|
+
fontSize: 10,
|
|
850
|
+
color: [40, 40, 40],
|
|
851
|
+
lineHeight: 1.45,
|
|
852
|
+
marker: "-",
|
|
853
|
+
},
|
|
854
|
+
|
|
855
|
+
// ── Pullquote ──
|
|
856
|
+
"talk.quote": {
|
|
857
|
+
fontFamily: "helvetica",
|
|
858
|
+
fontStyle: "italic",
|
|
859
|
+
fontSize: 11,
|
|
860
|
+
color: [30, 30, 30],
|
|
861
|
+
lineHeight: 1.4,
|
|
862
|
+
backgroundColor: [245, 245, 245],
|
|
863
|
+
paddingMm: 4,
|
|
864
|
+
marginTopPx: 6,
|
|
865
|
+
},
|
|
866
|
+
"talk.quote.attribution": {
|
|
867
|
+
fontFamily: "helvetica",
|
|
868
|
+
fontStyle: "normal",
|
|
869
|
+
fontSize: 9,
|
|
870
|
+
color: [100, 100, 100],
|
|
871
|
+
lineHeight: 1.2,
|
|
872
|
+
align: "right",
|
|
873
|
+
paddingLeftMm: 4,
|
|
874
|
+
paddingRightMm: 4,
|
|
875
|
+
paddingBottomMm: 4,
|
|
876
|
+
},
|
|
877
|
+
|
|
878
|
+
// ── Footer ──
|
|
879
|
+
"talk.footer.rule": {
|
|
880
|
+
color: [200, 200, 200],
|
|
881
|
+
lineWidth: 0.2,
|
|
882
|
+
marginBottomPx: 3,
|
|
883
|
+
},
|
|
884
|
+
"talk.footer.left": {
|
|
885
|
+
fontFamily: "helvetica",
|
|
886
|
+
fontStyle: "normal",
|
|
887
|
+
fontSize: 7.5,
|
|
888
|
+
color: [140, 140, 140],
|
|
889
|
+
lineHeight: 1.2,
|
|
890
|
+
},
|
|
891
|
+
"talk.footer.right": {
|
|
892
|
+
fontFamily: "helvetica",
|
|
893
|
+
fontStyle: "normal",
|
|
894
|
+
fontSize: 7.5,
|
|
895
|
+
color: [140, 140, 140],
|
|
896
|
+
lineHeight: 1.2,
|
|
897
|
+
},
|
|
898
|
+
},
|
|
899
|
+
};
|
|
900
|
+
```
|
|
901
|
+
|
|
902
|
+
**Key point:** the label name is just a string. The JSON source references it by that exact string. The core looks up the label, gets the style, and does the math. If the label is missing, the core throws - no silent fallbacks.
|
|
903
|
+
|
|
904
|
+
### Step 4: Write the source JSON
|
|
905
|
+
|
|
906
|
+
The source contains only content. Every operation says _what_ to render and _which label_ controls its appearance. The source never says _how_ to render.
|
|
907
|
+
|
|
908
|
+
```json
|
|
909
|
+
{
|
|
910
|
+
"pageTemplates": {
|
|
911
|
+
"footer": [
|
|
912
|
+
{ "type": "divider", "label": "talk.footer.rule" },
|
|
913
|
+
{
|
|
914
|
+
"type": "row",
|
|
915
|
+
"leftLabel": "talk.footer.left",
|
|
916
|
+
"rightLabel": "talk.footer.right",
|
|
917
|
+
"leftText": "DevConf 2026 - Talk Summaries",
|
|
918
|
+
"rightText": "Page {{page}}"
|
|
919
|
+
}
|
|
920
|
+
]
|
|
921
|
+
},
|
|
922
|
+
"sections": [
|
|
923
|
+
{
|
|
924
|
+
"type": "section",
|
|
925
|
+
"content": [
|
|
926
|
+
{ "type": "text", "label": "talk.title", "text": "Rethinking State Management in Large Applications" },
|
|
927
|
+
{
|
|
928
|
+
"type": "row",
|
|
929
|
+
"leftLabel": "talk.meta.left",
|
|
930
|
+
"rightLabel": "talk.meta.right",
|
|
931
|
+
"leftText": "By Jordan Lee",
|
|
932
|
+
"rightText": "March 14, 2026"
|
|
933
|
+
},
|
|
934
|
+
{ "type": "divider", "label": "talk.rule" }
|
|
935
|
+
]
|
|
936
|
+
},
|
|
937
|
+
{
|
|
938
|
+
"type": "section",
|
|
939
|
+
"content": [
|
|
940
|
+
{
|
|
941
|
+
"type": "text",
|
|
942
|
+
"label": "talk.body",
|
|
943
|
+
"text": [
|
|
944
|
+
"The talk opened with a survey of current state management patterns and their failure modes at scale. Most frameworks solve the easy case well but introduce friction the moment the dependency graph becomes non-trivial.",
|
|
945
|
+
"Lee argued that the problem is not the tools themselves but the assumption that all state belongs in the same container. The distinction between ephemeral UI state, cached server state, and derived computed state has practical consequences that most architectures ignore until it is too late.",
|
|
946
|
+
"The second half of the talk demonstrated a layered approach where each state category has its own lifecycle, persistence model, and invalidation strategy."
|
|
947
|
+
]
|
|
948
|
+
}
|
|
949
|
+
]
|
|
950
|
+
},
|
|
951
|
+
{
|
|
952
|
+
"type": "section",
|
|
953
|
+
"content": [
|
|
954
|
+
{ "type": "text", "label": "talk.heading", "text": "Key takeaways", "keepWithNext": 3 },
|
|
955
|
+
{
|
|
956
|
+
"type": "bullet",
|
|
957
|
+
"label": "talk.body",
|
|
958
|
+
"markerLabel": "talk.marker",
|
|
959
|
+
"bullets": [
|
|
960
|
+
"Separate ephemeral, cached, and derived state into distinct layers with independent lifecycles.",
|
|
961
|
+
"Treat server cache invalidation as a first-class concern rather than an afterthought.",
|
|
962
|
+
"Measure re-render cascades in production - synthetic benchmarks hide the real cost."
|
|
963
|
+
]
|
|
964
|
+
}
|
|
965
|
+
]
|
|
966
|
+
},
|
|
967
|
+
{
|
|
968
|
+
"type": "section",
|
|
969
|
+
"content": [
|
|
970
|
+
{
|
|
971
|
+
"type": "quote",
|
|
972
|
+
"label": "talk.quote",
|
|
973
|
+
"text": "If your state management library needs a diagram to explain how data flows through your own application, the library has already failed.",
|
|
974
|
+
"attribution": "- Jordan Lee"
|
|
975
|
+
}
|
|
976
|
+
]
|
|
977
|
+
}
|
|
978
|
+
]
|
|
979
|
+
}
|
|
980
|
+
```
|
|
981
|
+
|
|
982
|
+
### How the link works
|
|
983
|
+
|
|
984
|
+
Every operation in the JSON has a `label` (or `leftLabel`/`rightLabel` for rows, `markerLabel` for bullets). That string is a key into `theme.labels`. The core:
|
|
985
|
+
|
|
986
|
+
1. Looks up the label → gets the style object
|
|
987
|
+
2. Reads font, size, color, margins, padding from that style
|
|
988
|
+
3. Calculates how much space the content needs (wrapping, line height)
|
|
989
|
+
4. Draws the content and advances the cursor
|
|
990
|
+
|
|
991
|
+
```
|
|
992
|
+
Source operation: { "type": "text", "label": "talk.body", "text": "..." }
|
|
993
|
+
│
|
|
994
|
+
▼
|
|
995
|
+
Theme label: "talk.body": { fontSize: 10, lineHeight: 1.45, marginBottomPx: 5, ... }
|
|
996
|
+
│
|
|
997
|
+
▼
|
|
998
|
+
Core math: cursor += marginTop + paddingTop + (lines × lineHeightMm) + paddingBottom + marginBottom
|
|
999
|
+
```
|
|
1000
|
+
|
|
1001
|
+
**The source decides _what_ appears. The theme decides _how_ it looks. The core does the math. Nothing else.**
|
|
1002
|
+
|
|
1003
|
+
### Changing the look without touching content
|
|
1004
|
+
|
|
1005
|
+
To make the same content look like a different document, swap the theme. As long as the new theme defines the same label names, the JSON works unchanged.
|
|
1006
|
+
|
|
1007
|
+
For example, to make the talk summary feel more editorial:
|
|
1008
|
+
- Change `talk.title` to a serif font, left-aligned, 28pt
|
|
1009
|
+
- Change `talk.body` to a serif font, 11pt, `lineHeight: 1.5`
|
|
1010
|
+
- Change `talk.quote` to add a `leftBorder` instead of `backgroundColor`
|
|
1011
|
+
- Change `talk.rule` to use `dashPattern: [2, 1]`
|
|
1012
|
+
|
|
1013
|
+
The JSON stays identical. Different theme, different PDF.
|
|
1014
|
+
|
|
1015
|
+
---
|
|
1016
|
+
|
|
1017
|
+
## Tutorial: Using custom fonts
|
|
1018
|
+
|
|
1019
|
+
jsPDF ships with three built-in families: `helvetica`, `courier`, and `times` (each with normal, bold, italic, bolditalic). For anything else, you embed TTF files as base64.
|
|
1020
|
+
|
|
1021
|
+
### Step 1: Convert TTF to base64
|
|
1022
|
+
|
|
1023
|
+
```bash
|
|
1024
|
+
base64 -i Inter-Regular.ttf -o Inter-Regular.b64
|
|
1025
|
+
base64 -i Inter-Bold.ttf -o Inter-Bold.b64
|
|
1026
|
+
```
|
|
1027
|
+
|
|
1028
|
+
### Step 2: Load the base64 data in your theme
|
|
1029
|
+
|
|
1030
|
+
```js
|
|
1031
|
+
const fs = require("fs");
|
|
1032
|
+
|
|
1033
|
+
const INTER_REGULAR = fs.readFileSync(__dirname + "/fonts/Inter-Regular.b64", "utf8");
|
|
1034
|
+
const INTER_BOLD = fs.readFileSync(__dirname + "/fonts/Inter-Bold.b64", "utf8");
|
|
1035
|
+
|
|
1036
|
+
module.exports = {
|
|
1037
|
+
name: "Custom Font Theme",
|
|
1038
|
+
|
|
1039
|
+
page: {
|
|
1040
|
+
// ... page config ...
|
|
1041
|
+
defaultText: {
|
|
1042
|
+
fontFamily: "Inter", // use the custom font as default
|
|
1043
|
+
fontStyle: "normal",
|
|
1044
|
+
fontSize: 10,
|
|
1045
|
+
color: [0, 0, 0],
|
|
1046
|
+
lineHeight: 1.2,
|
|
1047
|
+
},
|
|
1048
|
+
// ... rest of page config ...
|
|
1049
|
+
},
|
|
1050
|
+
|
|
1051
|
+
customFonts: [
|
|
1052
|
+
{
|
|
1053
|
+
family: "Inter",
|
|
1054
|
+
faces: [
|
|
1055
|
+
{ style: "normal", fileName: "Inter-Regular.ttf", data: INTER_REGULAR },
|
|
1056
|
+
{ style: "bold", fileName: "Inter-Bold.ttf", data: INTER_BOLD },
|
|
1057
|
+
],
|
|
1058
|
+
},
|
|
1059
|
+
],
|
|
1060
|
+
|
|
1061
|
+
labels: {
|
|
1062
|
+
"doc.title": {
|
|
1063
|
+
fontFamily: "Inter", // now usable in any label
|
|
1064
|
+
fontStyle: "bold",
|
|
1065
|
+
fontSize: 18,
|
|
1066
|
+
color: [0, 0, 0],
|
|
1067
|
+
lineHeight: 1.2,
|
|
1068
|
+
},
|
|
1069
|
+
// ...
|
|
1070
|
+
},
|
|
1071
|
+
};
|
|
1072
|
+
```
|
|
1073
|
+
|
|
1074
|
+
### What happens under the hood
|
|
1075
|
+
|
|
1076
|
+
1. `registerThemeFonts()` iterates `customFonts`
|
|
1077
|
+
2. For each face, it calls `core.registerFont()` which does:
|
|
1078
|
+
- `doc.addFileToVFS(fileName, base64Data)` - loads the font data into jsPDF's virtual filesystem
|
|
1079
|
+
- `doc.addFont(fileName, family, style)` - registers the font under the family/style name
|
|
1080
|
+
3. After registration, any label can use `fontFamily: "Inter"` with `fontStyle: "normal"` or `"bold"`
|
|
1081
|
+
|
|
1082
|
+
### Rules
|
|
1083
|
+
|
|
1084
|
+
- Only TTF format is supported
|
|
1085
|
+
- The `fileName` must end in `.ttf`
|
|
1086
|
+
- The `family` string must match exactly between `customFonts` and label `fontFamily`
|
|
1087
|
+
- Each face needs its own file - there's no automatic bold/italic synthesis
|
|
1088
|
+
- If you reference a font style that wasn't registered, jsPDF will throw
|
|
1089
|
+
|
|
1090
|
+
---
|
|
1091
|
+
|
|
1092
|
+
## Common patterns
|
|
1093
|
+
|
|
1094
|
+
### Reusing labels across operation types
|
|
1095
|
+
|
|
1096
|
+
A bullet's text style and a paragraph can share the same label. The label just defines how text looks - the operation type decides the layout.
|
|
1097
|
+
|
|
1098
|
+
```json
|
|
1099
|
+
{ "type": "text", "label": "doc.body", "text": "A paragraph." }
|
|
1100
|
+
{ "type": "bullet", "label": "doc.body", "markerLabel": "doc.marker", "text": "A bullet." }
|
|
1101
|
+
```
|
|
1102
|
+
|
|
1103
|
+
Both render at the same font size, color, and line height. The bullet just adds a marker and indent.
|
|
1104
|
+
|
|
1105
|
+
### Keeping headings with content
|
|
1106
|
+
|
|
1107
|
+
Use `keepWithNext` to prevent a heading from being orphaned at the bottom of a page:
|
|
1108
|
+
|
|
1109
|
+
```json
|
|
1110
|
+
{ "type": "text", "label": "doc.heading", "text": "Section Title", "keepWithNext": 3 }
|
|
1111
|
+
{ "type": "text", "label": "doc.body", "text": "First paragraph." }
|
|
1112
|
+
{ "type": "text", "label": "doc.body", "text": "Second paragraph." }
|
|
1113
|
+
{ "type": "text", "label": "doc.body", "text": "Third paragraph." }
|
|
1114
|
+
```
|
|
1115
|
+
|
|
1116
|
+
`keepWithNext: 3` means: measure me + the next 3 operations as a group. If the group doesn't fit on this page, move the whole group to the next page.
|
|
1117
|
+
|
|
1118
|
+
### Repeating footers with page numbers
|
|
1119
|
+
|
|
1120
|
+
```json
|
|
1121
|
+
{
|
|
1122
|
+
"pageTemplates": {
|
|
1123
|
+
"footer": [
|
|
1124
|
+
{ "type": "divider", "label": "doc.footer.rule" },
|
|
1125
|
+
{
|
|
1126
|
+
"type": "row",
|
|
1127
|
+
"leftLabel": "doc.footer.left",
|
|
1128
|
+
"rightLabel": "doc.footer.right",
|
|
1129
|
+
"leftText": "Document Title",
|
|
1130
|
+
"rightText": "Page {{page}}"
|
|
1131
|
+
}
|
|
1132
|
+
],
|
|
1133
|
+
"footerHeightMm": 10
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
```
|
|
1137
|
+
|
|
1138
|
+
The `{{page}}` token resolves to the current page number. The footer renders on every page, including page 1.
|
|
1139
|
+
|
|
1140
|
+
### Pullquotes with background
|
|
1141
|
+
|
|
1142
|
+
The `quote` type creates a block. If the quote's label has `backgroundColor`, the background wraps the entire quote + attribution:
|
|
1143
|
+
|
|
1144
|
+
```js
|
|
1145
|
+
// Theme
|
|
1146
|
+
"pullquote": {
|
|
1147
|
+
fontFamily: "helvetica",
|
|
1148
|
+
fontStyle: "italic",
|
|
1149
|
+
fontSize: 11,
|
|
1150
|
+
color: [30, 30, 30],
|
|
1151
|
+
lineHeight: 1.4,
|
|
1152
|
+
backgroundColor: [245, 245, 240],
|
|
1153
|
+
paddingMm: 4,
|
|
1154
|
+
},
|
|
1155
|
+
"pullquote.attribution": {
|
|
1156
|
+
fontFamily: "helvetica",
|
|
1157
|
+
fontStyle: "normal",
|
|
1158
|
+
fontSize: 9,
|
|
1159
|
+
color: [100, 100, 100],
|
|
1160
|
+
lineHeight: 1.2,
|
|
1161
|
+
align: "right",
|
|
1162
|
+
paddingLeftMm: 4,
|
|
1163
|
+
paddingRightMm: 4,
|
|
1164
|
+
paddingBottomMm: 4,
|
|
1165
|
+
},
|
|
1166
|
+
```
|
|
1167
|
+
|
|
1168
|
+
```json
|
|
1169
|
+
{
|
|
1170
|
+
"type": "quote",
|
|
1171
|
+
"label": "pullquote",
|
|
1172
|
+
"text": "The quoted text.",
|
|
1173
|
+
"attribution": "- Author"
|
|
1174
|
+
}
|
|
1175
|
+
```
|
|
1176
|
+
|
|
1177
|
+
The attribution label defaults to `label + ".attribution"` - so `"pullquote"` automatically looks for `"pullquote.attribution"`. You can override with `attributionLabel`.
|
|
1178
|
+
|
|
1179
|
+
### Text array expansion
|
|
1180
|
+
|
|
1181
|
+
Instead of repeating the same operation for multiple paragraphs:
|
|
1182
|
+
|
|
1183
|
+
```json
|
|
1184
|
+
{
|
|
1185
|
+
"type": "text",
|
|
1186
|
+
"label": "doc.body",
|
|
1187
|
+
"text": [
|
|
1188
|
+
"First paragraph.",
|
|
1189
|
+
"Second paragraph.",
|
|
1190
|
+
"Third paragraph."
|
|
1191
|
+
]
|
|
1192
|
+
}
|
|
1193
|
+
```
|
|
1194
|
+
|
|
1195
|
+
Each string becomes its own text operation with the same label, including independent wrapping, margins, and page break handling. Same pattern works for `bullet` with the `bullets` array key.
|
|
1196
|
+
|
|
1197
|
+
### Preventing page breaks from cutting content
|
|
1198
|
+
|
|
1199
|
+
The engine breaks pages automatically when the cursor reaches the bottom margin. To prevent a page break from splitting related content, you must declare the parent-child relationship in the JSON using nesting.
|
|
1200
|
+
|
|
1201
|
+
**`keepWithNext`** - keeps a fixed number of sequential operations together:
|
|
1202
|
+
```json
|
|
1203
|
+
{ "type": "text", "label": "heading", "text": "Title", "keepWithNext": 2 }
|
|
1204
|
+
{ "type": "text", "label": "body", "text": "First paragraph." }
|
|
1205
|
+
{ "type": "text", "label": "body", "text": "Second paragraph." }
|
|
1206
|
+
```
|
|
1207
|
+
The heading + next 2 operations are measured as a group. If the group doesn't fit, all three move to the next page.
|
|
1208
|
+
|
|
1209
|
+
**`block` with `keepTogether`** - keeps an entire nested group together:
|
|
1210
|
+
```json
|
|
1211
|
+
{
|
|
1212
|
+
"type": "block",
|
|
1213
|
+
"keepTogether": true,
|
|
1214
|
+
"children": [
|
|
1215
|
+
{ "type": "text", "label": "card.title", "text": "Card heading" },
|
|
1216
|
+
{ "type": "text", "label": "card.body", "text": "Card content." },
|
|
1217
|
+
{ "type": "divider", "label": "card.rule" }
|
|
1218
|
+
]
|
|
1219
|
+
}
|
|
1220
|
+
```
|
|
1221
|
+
The entire block is measured. If it doesn't fit on the current page, the whole block moves to the next page.
|
|
1222
|
+
|
|
1223
|
+
**`section`** - groups content but allows page breaks inside:
|
|
1224
|
+
```json
|
|
1225
|
+
{
|
|
1226
|
+
"type": "section",
|
|
1227
|
+
"content": [
|
|
1228
|
+
{ "type": "text", "label": "heading", "text": "Section Title", "keepWithNext": 1 },
|
|
1229
|
+
{ "type": "text", "label": "body", "text": "Long content that can break across pages." }
|
|
1230
|
+
]
|
|
1231
|
+
}
|
|
1232
|
+
```
|
|
1233
|
+
Sections default to `keepTogether: false`. Use them for logical grouping without forcing everything onto one page. Use `keepWithNext` on the heading to at least keep it with the first paragraph.
|
|
1234
|
+
|
|
1235
|
+
### Labels: theme defines, JSON selects
|
|
1236
|
+
|
|
1237
|
+
A theme can define any number of labels. The JSON only uses the ones it needs. **Order of labels in the theme doesn't matter.** Only the labels referenced by the JSON's operations are used - the rest are ignored.
|
|
1238
|
+
|
|
1239
|
+
```js
|
|
1240
|
+
// Theme defines 10 labels
|
|
1241
|
+
labels: {
|
|
1242
|
+
"news.headline": { ... },
|
|
1243
|
+
"news.body": { ... },
|
|
1244
|
+
"news.byline": { ... },
|
|
1245
|
+
"news.footer": { ... },
|
|
1246
|
+
// ... 6 more
|
|
1247
|
+
}
|
|
1248
|
+
```
|
|
1249
|
+
|
|
1250
|
+
```json
|
|
1251
|
+
// Source only uses 3 of them - that's fine
|
|
1252
|
+
{ "type": "text", "label": "news.headline", "text": "..." }
|
|
1253
|
+
{ "type": "text", "label": "news.body", "text": "..." }
|
|
1254
|
+
{ "type": "text", "label": "news.body", "text": "..." }
|
|
1255
|
+
```
|
|
1256
|
+
|
|
1257
|
+
The rule: if a label is used in the JSON, it **must** exist in the theme. If a label exists in the theme but isn't used in the JSON, nothing happens - it's just available. This means you can build a single theme with labels for many document types and reuse it across different sources.
|
|
1258
|
+
|
|
1259
|
+
---
|
|
1260
|
+
|
|
1261
|
+
## CLI
|
|
1262
|
+
|
|
1263
|
+
Render a source JSON + theme into a PDF from the command line.
|
|
1264
|
+
|
|
1265
|
+
```bash
|
|
1266
|
+
node cli.js --source <file.json> --theme <name|path> --output <file.pdf>
|
|
1267
|
+
```
|
|
1268
|
+
|
|
1269
|
+
### Options
|
|
1270
|
+
|
|
1271
|
+
| Flag | Short | Description |
|
|
1272
|
+
|---|---|---|
|
|
1273
|
+
| `--source` | `-s` | Path to source JSON file (or pipe via stdin) |
|
|
1274
|
+
| `--theme` | `-t` | Built-in theme name or path to a `.js` theme file |
|
|
1275
|
+
| `--output` | `-o` | Output PDF path (default: `output/cli-output.pdf`) |
|
|
1276
|
+
| `--help` | `-h` | Show help |
|
|
1277
|
+
|
|
1278
|
+
### Built-in themes
|
|
1279
|
+
|
|
1280
|
+
The CLI auto-discovers themes in `examples/themes/`. Use just the name:
|
|
1281
|
+
|
|
1282
|
+
```bash
|
|
1283
|
+
node cli.js -s my-source.json -t newsprint -o output/my-newspaper.pdf
|
|
1284
|
+
node cli.js -s my-source.json -t editorial -o output/my-magazine.pdf
|
|
1285
|
+
node cli.js -s my-source.json -t default -o output/my-doc.pdf
|
|
1286
|
+
```
|
|
1287
|
+
|
|
1288
|
+
Available: `default`, `editorial`, `newsprint`, `corporate`, `ceremony`, `program`.
|
|
1289
|
+
|
|
1290
|
+
### Custom theme file
|
|
1291
|
+
|
|
1292
|
+
Point to any `.js` file that exports a theme object:
|
|
1293
|
+
|
|
1294
|
+
```bash
|
|
1295
|
+
node cli.js -s my-source.json -t ./my-custom-theme.js -o output/custom.pdf
|
|
1296
|
+
```
|
|
1297
|
+
|
|
1298
|
+
### Piping JSON via stdin
|
|
1299
|
+
|
|
1300
|
+
```bash
|
|
1301
|
+
cat my-source.json | node cli.js -t default -o output/piped.pdf
|
|
1302
|
+
```
|
|
1303
|
+
|
|
1304
|
+
Or generate JSON dynamically:
|
|
1305
|
+
|
|
1306
|
+
```bash
|
|
1307
|
+
echo '{"operations":[{"type":"text","label":"doc.title","text":"Hello"}]}' | node cli.js -t default -o output/hello.pdf
|
|
1308
|
+
```
|
|
1309
|
+
|
|
1310
|
+
### Examples
|
|
1311
|
+
|
|
1312
|
+
Render the newspaper layout:
|
|
1313
|
+
```bash
|
|
1314
|
+
node cli.js -s examples/sources/source-newspaper-hugopalma-arc.json -t newsprint -o output/newspaper.pdf
|
|
1315
|
+
```
|
|
1316
|
+
|
|
1317
|
+
Render the event program:
|
|
1318
|
+
```bash
|
|
1319
|
+
node cli.js -s examples/sources/source-event-program.json -t program -o output/program.pdf
|
|
1320
|
+
```
|
|
1321
|
+
|
|
1322
|
+
Render all examples at once:
|
|
1323
|
+
```bash
|
|
1324
|
+
node examples/generate-all.js
|
|
1325
|
+
```
|
|
1326
|
+
|
|
1327
|
+
---
|
|
1328
|
+
|
|
1329
|
+
## Plugins
|
|
1330
|
+
|
|
1331
|
+
Plugins extend the operation set with custom operation types. The engine dispatches any unrecognized `type` to the registered plugin for that type.
|
|
1332
|
+
|
|
1333
|
+
### Registering a plugin
|
|
1334
|
+
|
|
1335
|
+
```js
|
|
1336
|
+
const { registerPlugin, plugins } = require("./index");
|
|
1337
|
+
|
|
1338
|
+
registerPlugin("chart", plugins.chart);
|
|
1339
|
+
```
|
|
1340
|
+
|
|
1341
|
+
After registration, any source operation with `{ "type": "chart", ... }` is routed to `plugins.chart`.
|
|
1342
|
+
|
|
1343
|
+
### Plugin contract
|
|
1344
|
+
|
|
1345
|
+
A plugin is an object with:
|
|
1346
|
+
|
|
1347
|
+
```js
|
|
1348
|
+
{
|
|
1349
|
+
// Called during rendering. Must be synchronous.
|
|
1350
|
+
render(ctx) {
|
|
1351
|
+
const { core, operation, bounds, theme, index } = ctx;
|
|
1352
|
+
// core - PDFCore instance (cursor, drawImage, drawText, etc.)
|
|
1353
|
+
// operation - the raw operation object from the source
|
|
1354
|
+
// bounds - { left, right } content area in mm
|
|
1355
|
+
// theme - resolved runtime theme
|
|
1356
|
+
// index - operation index string (for error messages)
|
|
1357
|
+
},
|
|
1358
|
+
|
|
1359
|
+
// Optional. Returns estimated height in mm for keepWithNext/page break math.
|
|
1360
|
+
estimateHeight(ctx) {
|
|
1361
|
+
return 80;
|
|
1362
|
+
},
|
|
1363
|
+
|
|
1364
|
+
// Optional. Called before rendering to validate the operation. Throw to reject.
|
|
1365
|
+
validate(operation) {
|
|
1366
|
+
if (!operation.requiredField) throw new Error("...");
|
|
1367
|
+
},
|
|
1368
|
+
}
|
|
1369
|
+
```
|
|
1370
|
+
|
|
1371
|
+
`render` must be synchronous. For plugins that need async work (e.g., network fetches, canvas rendering), pre-render before calling `renderDocument`, see the Chart plugin section below.
|
|
1372
|
+
|
|
1373
|
+
---
|
|
1374
|
+
|
|
1375
|
+
## Chart plugin
|
|
1376
|
+
|
|
1377
|
+
The built-in chart plugin renders Chart.js charts server-side via `chartjs-node-canvas` and embeds the result as a PNG image.
|
|
1378
|
+
|
|
1379
|
+
### Requirements
|
|
1380
|
+
|
|
1381
|
+
The chart plugin requires the `canvas` npm package (native C++ addon). Chart.js and chartjs-node-canvas are vendored and ship with the engine.
|
|
1382
|
+
|
|
1383
|
+
```bash
|
|
1384
|
+
npm install canvas
|
|
1385
|
+
```
|
|
1386
|
+
|
|
1387
|
+
### Registration
|
|
1388
|
+
|
|
1389
|
+
```js
|
|
1390
|
+
const { renderDocument, registerPlugin, plugins } = require("./index");
|
|
1391
|
+
|
|
1392
|
+
registerPlugin("chart", plugins.chart);
|
|
1393
|
+
```
|
|
1394
|
+
|
|
1395
|
+
### Pre-rendering
|
|
1396
|
+
|
|
1397
|
+
The chart plugin is synchronous during `renderDocument`, but Chart.js rendering is async. You must call `plugins.chart.preRender(operation)` before `renderDocument`. This renders the chart to a PNG buffer and caches it on the operation object.
|
|
1398
|
+
|
|
1399
|
+
```js
|
|
1400
|
+
const chartOp = {
|
|
1401
|
+
type: "chart",
|
|
1402
|
+
chartType: "bar",
|
|
1403
|
+
widthMm: 160,
|
|
1404
|
+
heightMm: 90,
|
|
1405
|
+
canvasWidth: 1600,
|
|
1406
|
+
canvasHeight: 900,
|
|
1407
|
+
data: {
|
|
1408
|
+
labels: ["Q1", "Q2", "Q3", "Q4"],
|
|
1409
|
+
datasets: [{ label: "Revenue", data: [120000, 145000, 138000, 172000] }]
|
|
1410
|
+
},
|
|
1411
|
+
options: {
|
|
1412
|
+
scales: { y: { beginAtZero: true } },
|
|
1413
|
+
layout: { padding: { bottom: 10 } }
|
|
1414
|
+
}
|
|
1415
|
+
};
|
|
1416
|
+
|
|
1417
|
+
async function main() {
|
|
1418
|
+
await plugins.chart.preRender(chartOp);
|
|
1419
|
+
|
|
1420
|
+
renderDocument({
|
|
1421
|
+
source: { sections: [{ type: "section", content: [chartOp] }] },
|
|
1422
|
+
theme,
|
|
1423
|
+
outputPath: "output/chart.pdf",
|
|
1424
|
+
});
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
main();
|
|
1428
|
+
```
|
|
1429
|
+
|
|
1430
|
+
`preRender` mutates the operation in place, attaching `operation._buf` (a PNG Buffer). The sync `render` step reads that buffer and calls `core.drawImage`.
|
|
1431
|
+
|
|
1432
|
+
### Operation fields
|
|
1433
|
+
|
|
1434
|
+
| Field | Required | Type | Description |
|
|
1435
|
+
|---|---|---|---|
|
|
1436
|
+
| `chartType` | yes | string | Chart.js type: `"bar"`, `"line"`, `"doughnut"`, etc. |
|
|
1437
|
+
| `data` | yes | object | Chart.js `data` config (`labels` + `datasets`) |
|
|
1438
|
+
| `widthMm` | no | number | Width in the PDF in mm (default: content area width) |
|
|
1439
|
+
| `heightMm` | no | number | Height in the PDF in mm (default: 80) |
|
|
1440
|
+
| `canvasWidth` | no | number | Canvas render width in pixels (default: 1600) |
|
|
1441
|
+
| `canvasHeight` | no | number | Canvas render height in pixels (default: 800) |
|
|
1442
|
+
| `options` | no | object | Chart.js `options` object. `responsive: false` and `animation: false` are injected automatically. |
|
|
1443
|
+
| `xMm` | no | number | Left edge in mm (default: content left margin) |
|
|
1444
|
+
|
|
1445
|
+
### Resolution
|
|
1446
|
+
|
|
1447
|
+
`canvasWidth` / `canvasHeight` control sharpness. `widthMm` / `heightMm` control the slot size in the PDF. Keep their aspect ratio consistent:
|
|
1448
|
+
|
|
1449
|
+
```
|
|
1450
|
+
canvasWidth / canvasHeight ≈ widthMm / heightMm
|
|
1451
|
+
```
|
|
1452
|
+
|
|
1453
|
+
For a 160mm × 90mm slot, `canvasWidth: 1600, canvasHeight: 900` gives a clean 10px/mm density, sharp at any reasonable zoom.
|
|
1454
|
+
|
|
1455
|
+
### Axis label clipping
|
|
1456
|
+
|
|
1457
|
+
Chart.js does not automatically reserve space for tick labels outside the chart area. If x-axis labels are cut off, add bottom padding via `options.layout`:
|
|
1458
|
+
|
|
1459
|
+
```js
|
|
1460
|
+
options: {
|
|
1461
|
+
layout: { padding: { bottom: 10 } }
|
|
1462
|
+
}
|
|
1463
|
+
```
|
|
1464
|
+
|
|
1465
|
+
### Writing a custom plugin
|
|
1466
|
+
|
|
1467
|
+
Any module that implements the plugin contract can be registered:
|
|
1468
|
+
|
|
1469
|
+
```js
|
|
1470
|
+
const myPlugin = {
|
|
1471
|
+
render({ core, operation, bounds }) {
|
|
1472
|
+
const widthMm = operation.widthMm || (bounds.right - bounds.left);
|
|
1473
|
+
const heightMm = operation.heightMm || 40;
|
|
1474
|
+
const x = bounds.left;
|
|
1475
|
+
|
|
1476
|
+
core.ensureSpace(heightMm);
|
|
1477
|
+
const y = core.getCursorY();
|
|
1478
|
+
// ... draw using core.doc (the jsPDF instance) ...
|
|
1479
|
+
core.setCursorY(y + heightMm);
|
|
1480
|
+
},
|
|
1481
|
+
|
|
1482
|
+
estimateHeight({ operation }) {
|
|
1483
|
+
return (operation.heightMm || 40) + 4;
|
|
1484
|
+
},
|
|
1485
|
+
};
|
|
1486
|
+
|
|
1487
|
+
registerPlugin("myType", myPlugin);
|
|
1488
|
+
```
|
|
1489
|
+
|
|
1490
|
+
`core.doc` is the raw jsPDF instance. Anything jsPDF can draw, a plugin can draw.
|