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.
@@ -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.