h17-sspdf 1.0.2 → 1.2.0

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 CHANGED
@@ -240,6 +240,7 @@ Shared layout defaults read by operation handlers.
240
240
  layout: {
241
241
  bulletIndentMm: 4.5, // text indent after bullet marker (default: 4)
242
242
  chartAlign: "center", // chart horizontal alignment: "left" (default) or "center"
243
+ columnGutterMm: 5, // default gutter between columns (required if columns ops omit gutterMm)
243
244
  }
244
245
  ```
245
246
 
@@ -645,6 +646,39 @@ Identical to `block` but defaults to `keepTogether: false`. Use sections to grou
645
646
 
646
647
  Alias for `block`.
647
648
 
649
+ #### `columns`
650
+
651
+ Renders two independent operation lists side by side. Each column gets half the content width minus half the gutter. The cursor advances to the bottom of the taller column, so subsequent full-width operations continue from the correct position.
652
+
653
+ ```json
654
+ {
655
+ "type": "columns",
656
+ "gutterMm": 5,
657
+ "column1": [
658
+ { "type": "text", "label": "doc.heading", "text": "Left Column" },
659
+ { "type": "text", "label": "doc.body", "text": "Content on the left." }
660
+ ],
661
+ "column2": [
662
+ { "type": "text", "label": "doc.heading", "text": "Right Column" },
663
+ { "type": "text", "label": "doc.body", "text": "Content on the right." }
664
+ ]
665
+ }
666
+ ```
667
+
668
+ | Field | Required | Type | Description |
669
+ |---|---|---|---|
670
+ | `column1` | yes | array | Operations for the left column |
671
+ | `column2` | yes | array | Operations for the right column |
672
+ | `gutterMm` | no* | number | Gap between columns in mm. *Required if `theme.layout.columnGutterMm` is not set |
673
+
674
+ **Gutter resolution:** `gutterMm` on the operation takes precedence. If omitted, `theme.layout.columnGutterMm` is used. If neither is defined, the engine throws.
675
+
676
+ **Column width:** `(contentWidth - gutterMm) / 2`. Both columns are always equal width.
677
+
678
+ **Cursor after:** `max(col1EndY, col2EndY)` - the bottom of the taller column.
679
+
680
+ Any operation type that works at the top level works inside columns. Tables, images, bullets, nested blocks all work. Columns inside columns are supported (the margin stack is re-entrant).
681
+
648
682
  ### Inferred text operations
649
683
 
650
684
  If a node has `label` and `text` (or `value`) but no `type`, it's treated as text:
@@ -675,6 +709,7 @@ The engine positions content using a cursor that starts at `marginTopMm` and mov
675
709
  | `table` | marginTop + headerRowHeight + sum(dataRowHeights) + marginBottom |
676
710
  | `image` | marginTop + paddingTop + imageHeightMm + captionHeight + paddingBottom + marginBottom |
677
711
  | `block` | sum of children deltas (+ spaceAfter if defined) |
712
+ | `columns` | max(col1Height, col2Height) |
678
713
 
679
714
  Where:
680
715
  - `lineHeightMm = (fontSize × 25.4/72) × lineHeight` (font points → mm × multiplier)
@@ -1350,6 +1385,7 @@ This applies to all color properties: `color`, `backgroundColor`, `borderColor`,
1350
1385
  | `section` | `content` | `label`, `keepTogether` |
1351
1386
  | `quote` | `label`, `text` | `attribution`, `attributionLabel` |
1352
1387
  | `hiddenText` | `label`, `text` | - |
1388
+ | `columns` | `column1`, `column2` | `gutterMm` (falls back to `theme.layout.columnGutterMm`) |
1353
1389
 
1354
1390
  Every operation also accepts `xMm` and `maxWidthMm` to override the theme margins for that operation only.
1355
1391
 
package/README.md CHANGED
@@ -20,7 +20,7 @@ Source JSON + Theme = PDF
20
20
  npm install h17-sspdf
21
21
  ```
22
22
 
23
- Requires **Node.js 18 or newer**. The engine vendors a single self-contained UMD build of jsPDF (which bundles fflate, fast-png, iobuffer internally) plus Chart.js and chartjs-node-canvas. The only install-time dependency is [`canvas`](https://www.npmjs.com/package/canvas) (native C++ addon, used by the chart plugin) — no transitive dependency tree beyond that.
23
+ Requires **Node.js 18 or newer**. The engine vendors a single self-contained UMD build of jsPDF (which bundles fflate, fast-png, iobuffer internally) plus Chart.js and chartjs-node-canvas. The base install has no runtime dependencies. The optional chart plugin uses [`canvas`](https://www.npmjs.com/package/canvas) as an optional peer dependency.
24
24
 
25
25
  ## The problem it solves
26
26
 
@@ -199,7 +199,7 @@ A repeating pattern of `row` + `text` pairs. The row carries the label/value, th
199
199
  "type": "quote",
200
200
  "label": "news.pullquote",
201
201
  "text": "When the format becomes a system instead of a template, agencies stop re-solving the same layout problem every week.",
202
- "attribution": " Elena Ward, public records modernization lead",
202
+ "attribution": "- Elena Ward, public records modernization lead",
203
203
  "xMm": 22,
204
204
  "maxWidthMm": 166
205
205
  }
@@ -270,6 +270,7 @@ labels: {
270
270
  | `quote` | Blockquote with attribution | `label`, `text`, `attribution` |
271
271
  | `block` | Group children, optional background + border | `children`, `keepTogether` |
272
272
  | `section` | Logical group, allows breaks inside | `content` |
273
+ | `columns` | Two-column side-by-side layout | `column1`, `column2`, `gutterMm` |
273
274
 
274
275
  ### Position overrides
275
276
 
@@ -473,7 +474,7 @@ Claude Code skills for generating PDFs and themes are available in the `skills/`
473
474
  - Single-line `row` cells, no multi-line column pairs
474
475
  - `{{page}}` gives the current page number; `{{pages}}` (total page count) is not supported because keep-together rules make the final page count unpredictable until the last operation is laid out
475
476
  - Charts require the `canvas` npm package (native C++ addon) for server-side rendering; everything else is zero native dependencies
476
- - A `jspdf.umd.js` build is vendored for client-side/browser use. It bundles all dependencies internally but requires wiring up your own entry point `pdf-core.js` uses the Node build by default
477
+ - A `jspdf.umd.js` build is vendored for client-side/browser use. It bundles all dependencies internally but requires wiring up your own entry point; `pdf-core.js` uses the Node build by default
477
478
 
478
479
  ---
479
480
 
package/cli.js CHANGED
@@ -155,7 +155,7 @@ function collectChartOps(obj) {
155
155
  obj.forEach((item) => charts.push(...collectChartOps(item)));
156
156
  } else {
157
157
  if (obj.type === "chart") charts.push(obj);
158
- for (const key of ["operations", "sections", "content", "items", "children"]) {
158
+ for (const key of ["operations", "sections", "content", "items", "children", "column1", "column2"]) {
159
159
  if (Array.isArray(obj[key])) charts.push(...collectChartOps(obj[key]));
160
160
  }
161
161
  if (obj.pageTemplates) {
package/core/pdf-core.js CHANGED
@@ -278,6 +278,33 @@ class PDFCore {
278
278
  this.cursorY = Number(y) || this.cursorY;
279
279
  }
280
280
 
281
+ /**
282
+ * Narrow the content area to a column band. Saves current margins and cursor.
283
+ * @param {number} left Absolute left x in mm
284
+ * @param {number} right Absolute right x in mm
285
+ */
286
+ _enterColumnBounds(left, right) {
287
+ if (!this._colMarginStack) this._colMarginStack = [];
288
+ this._colMarginStack.push({ left: this.marginLeftMm, right: this.marginRightMm });
289
+ this.marginLeftMm = left;
290
+ this.marginRightMm = this.pageWidth - right;
291
+ }
292
+
293
+ /**
294
+ * Restore margins saved by _enterColumnBounds. Returns the cursor position
295
+ * reached at end of column so the caller can track the tallest column.
296
+ * @param {number} savedCursorY The cursor to restore after saving column end Y
297
+ * @returns {number} Cursor Y at end of column
298
+ */
299
+ _exitColumnBounds(savedCursorY) {
300
+ const endY = this.cursorY;
301
+ const saved = this._colMarginStack.pop();
302
+ this.marginLeftMm = saved.left;
303
+ this.marginRightMm = saved.right;
304
+ this.cursorY = savedCursorY;
305
+ return endY;
306
+ }
307
+
281
308
  /**
282
309
  * Apply a text style to jsPDF.
283
310
  * @param {object} style
@@ -50,6 +50,10 @@
50
50
  */
51
51
 
52
52
  let _ChartJSNodeCanvas = null;
53
+ const _rendererCache = new Map();
54
+ const MAX_RENDERER_CACHE_SIZE = 8;
55
+ const DEFAULT_CANVAS_WIDTH = 1600;
56
+ const DEFAULT_CANVAS_HEIGHT = 800;
53
57
 
54
58
  function getCanvas() {
55
59
  if (!_ChartJSNodeCanvas) {
@@ -64,6 +68,46 @@ function getCanvas() {
64
68
  return _ChartJSNodeCanvas;
65
69
  }
66
70
 
71
+ function getRenderer(width, height) {
72
+ const ChartJSNodeCanvas = getCanvas();
73
+ const key = `${width}x${height}`;
74
+ if (_rendererCache.has(key)) {
75
+ return _rendererCache.get(key);
76
+ }
77
+ if (_rendererCache.size >= MAX_RENDERER_CACHE_SIZE) {
78
+ const oldestKey = _rendererCache.keys().next().value;
79
+ _rendererCache.delete(oldestKey);
80
+ }
81
+ const renderer = new ChartJSNodeCanvas({
82
+ width,
83
+ height,
84
+ backgroundColour: 'transparent',
85
+ });
86
+ _rendererCache.set(key, renderer);
87
+ return renderer;
88
+ }
89
+
90
+ function resolvePositiveNumber(value, fallback, name) {
91
+ if (value === undefined || value === null || value === '') {
92
+ return fallback;
93
+ }
94
+ const numberValue = Number(value);
95
+ if (!Number.isFinite(numberValue) || numberValue <= 0) {
96
+ throw new Error(`chart plugin: ${name} must be a positive number`);
97
+ }
98
+ return numberValue;
99
+ }
100
+
101
+ function cloneChartValue(value) {
102
+ if (!value || typeof value !== 'object') return value;
103
+ if (Array.isArray(value)) return value.map(cloneChartValue);
104
+ const out = {};
105
+ for (const key of Object.keys(value)) {
106
+ out[key] = cloneChartValue(value[key]);
107
+ }
108
+ return out;
109
+ }
110
+
67
111
  /**
68
112
  * Pre-render the chart to a PNG buffer and cache it on the operation.
69
113
  * Call this before renderDocument() for any source containing chart operations.
@@ -77,35 +121,28 @@ function getCanvas() {
77
121
  *
78
122
  * Supported token: {{v}} - replaced with the callback's first argument (value).
79
123
  */
80
- function resolveCallbackTemplates(obj) {
81
- if (!obj || typeof obj !== 'object') return obj;
82
- if (Array.isArray(obj)) {
83
- for (let i = 0; i < obj.length; i++) {
84
- obj[i] = resolveCallbackTemplates(obj[i]);
85
- }
86
- return obj;
87
- }
88
- for (const key of Object.keys(obj)) {
89
- if (key === 'callback' && typeof obj[key] === 'string' && obj[key].includes('{{v}}')) {
90
- const template = obj[key];
91
- obj[key] = function (value) { return template.replace(/\{\{v\}\}/g, String(value)); };
124
+ function resolveCallbackTemplates(value) {
125
+ if (!value || typeof value !== 'object') return value;
126
+ if (Array.isArray(value)) return value.map(resolveCallbackTemplates);
127
+
128
+ const out = {};
129
+ for (const key of Object.keys(value)) {
130
+ if (key === 'callback' && typeof value[key] === 'string' && value[key].includes('{{v}}')) {
131
+ const template = value[key];
132
+ out[key] = function (callbackValue) {
133
+ return template.replace(/\{\{v\}\}/g, String(callbackValue));
134
+ };
92
135
  } else {
93
- obj[key] = resolveCallbackTemplates(obj[key]);
136
+ out[key] = resolveCallbackTemplates(value[key]);
94
137
  }
95
138
  }
96
- return obj;
139
+ return out;
97
140
  }
98
141
 
99
142
  async function preRender(operation) {
100
- const ChartJSNodeCanvas = getCanvas();
101
- const canvasW = operation.canvasWidth || 1600;
102
- const canvasH = operation.canvasHeight || 800;
103
-
104
- const canvas = new ChartJSNodeCanvas({
105
- width: canvasW,
106
- height: canvasH,
107
- backgroundColour: 'transparent',
108
- });
143
+ const canvasW = resolvePositiveNumber(operation.canvasWidth, DEFAULT_CANVAS_WIDTH, 'canvasWidth');
144
+ const canvasH = resolvePositiveNumber(operation.canvasHeight, DEFAULT_CANVAS_HEIGHT, 'canvasHeight');
145
+ const canvas = getRenderer(canvasW, canvasH);
109
146
 
110
147
  const options = resolveCallbackTemplates({
111
148
  ...(operation.options || {}),
@@ -115,7 +152,7 @@ async function preRender(operation) {
115
152
 
116
153
  operation._buf = await canvas.renderToBuffer({
117
154
  type: operation.chartType || 'bar',
118
- data: operation.data || { labels: [], datasets: [] },
155
+ data: cloneChartValue(operation.data || { labels: [], datasets: [] }),
119
156
  options,
120
157
  });
121
158
  }
@@ -3,6 +3,7 @@ const _plugins = new Map();
3
3
  const BUILT_IN_TYPES = new Set([
4
4
  "text", "row", "bullet", "divider", "spacer", "hiddenText",
5
5
  "block", "section", "group", "quote", "table", "image",
6
+ "columns", "pageBreak",
6
7
  ]);
7
8
 
8
9
  function registerPlugin(type, handler) {
@@ -284,6 +284,14 @@ function normalizeNode(node, path) {
284
284
  }];
285
285
  }
286
286
 
287
+ if (node.type === "columns") {
288
+ return [{
289
+ ...node,
290
+ column1: Array.isArray(node.column1) ? normalizeNodes(node.column1, `${path}.column1`) : [],
291
+ column2: Array.isArray(node.column2) ? normalizeNodes(node.column2, `${path}.column2`) : [],
292
+ }];
293
+ }
294
+
287
295
  if (isOperationType(node.type)) {
288
296
  return expandOperationNode(node, path);
289
297
  }
@@ -344,6 +352,7 @@ function isOperationType(type) {
344
352
  || type === "hiddenText"
345
353
  || type === "table"
346
354
  || type === "image"
355
+ || type === "columns"
347
356
  || hasPlugin(type);
348
357
  }
349
358
 
@@ -873,6 +882,39 @@ function executeOperation(ctx) {
873
882
  return;
874
883
  }
875
884
 
885
+ if (operation.type === "columns") {
886
+ const bounds = getHorizontalBounds(core, templateBypassMargins);
887
+ const contentWidth = bounds.right - bounds.left;
888
+ const gutterMm = resolveColumnsGutter(operation, theme, index);
889
+ const colWidth = (contentWidth - gutterMm) / 2;
890
+ const col1Left = bounds.left;
891
+ const col1Right = bounds.left + colWidth;
892
+ const col2Left = col1Right + gutterMm;
893
+ const col2Right = bounds.right;
894
+ const startY = core.getCursorY();
895
+
896
+ core._enterColumnBounds(col1Left, col1Right);
897
+ executeOperations({
898
+ core, theme,
899
+ operations: Array.isArray(operation.column1) ? operation.column1 : [],
900
+ indexPrefix: `${index}.col1.`,
901
+ templateMode, templateBypassMargins,
902
+ });
903
+ const col1EndY = core._exitColumnBounds(startY);
904
+
905
+ core._enterColumnBounds(col2Left, col2Right);
906
+ executeOperations({
907
+ core, theme,
908
+ operations: Array.isArray(operation.column2) ? operation.column2 : [],
909
+ indexPrefix: `${index}.col2.`,
910
+ templateMode, templateBypassMargins,
911
+ });
912
+ const col2EndY = core._exitColumnBounds(startY);
913
+
914
+ core.setCursorY(Math.max(col1EndY, col2EndY));
915
+ return;
916
+ }
917
+
876
918
  const plugin = getPlugin(operation.type);
877
919
  if (plugin) {
878
920
  const bounds = getHorizontalBounds(core, templateBypassMargins);
@@ -1120,6 +1162,35 @@ function estimateOperationHeight(ctx) {
1120
1162
  return margins.top + padding.top + imgHeightMm + captionHeight + padding.bottom + margins.bottom;
1121
1163
  }
1122
1164
 
1165
+ if (operation.type === "columns") {
1166
+ const contentWidth = core.pageWidth - core.marginLeftMm - core.marginRightMm;
1167
+ const gutterMm = resolveColumnsGutter(operation, theme, index);
1168
+ const colWidth = (contentWidth - gutterMm) / 2;
1169
+
1170
+ const savedMarginLeft = core.marginLeftMm;
1171
+ const savedMarginRight = core.marginRightMm;
1172
+
1173
+ core.marginLeftMm = savedMarginLeft;
1174
+ core.marginRightMm = core.pageWidth - (savedMarginLeft + colWidth);
1175
+ const col1H = estimateOperationsHeight({
1176
+ core, theme,
1177
+ operations: Array.isArray(operation.column1) ? operation.column1 : [],
1178
+ indexPrefix: `${index}.col1.`,
1179
+ });
1180
+
1181
+ core.marginLeftMm = savedMarginLeft + colWidth + gutterMm;
1182
+ core.marginRightMm = core.pageWidth - (savedMarginLeft + colWidth + gutterMm + colWidth);
1183
+ const col2H = estimateOperationsHeight({
1184
+ core, theme,
1185
+ operations: Array.isArray(operation.column2) ? operation.column2 : [],
1186
+ indexPrefix: `${index}.col2.`,
1187
+ });
1188
+
1189
+ core.marginLeftMm = savedMarginLeft;
1190
+ core.marginRightMm = savedMarginRight;
1191
+ return Math.max(col1H, col2H);
1192
+ }
1193
+
1123
1194
  const plugin = getPlugin(operation.type);
1124
1195
  if (plugin) {
1125
1196
  if (typeof plugin.estimateHeight === "function") {
@@ -1172,6 +1243,20 @@ function applyPageTokens(value, core) {
1172
1243
  return String(value).replace(/\{\{page\}\}/g, String(page));
1173
1244
  }
1174
1245
 
1246
+ function resolveColumnsGutter(operation, theme, index) {
1247
+ if (operation.gutterMm !== undefined) {
1248
+ return Number(operation.gutterMm);
1249
+ }
1250
+ const themeGutter = (theme.layout || {}).columnGutterMm;
1251
+ if (themeGutter !== undefined) {
1252
+ return Number(themeGutter);
1253
+ }
1254
+ throw new Error(
1255
+ `columns operation at index ${index} has no gutterMm. ` +
1256
+ `Set gutterMm on the operation or columnGutterMm in theme.layout.`
1257
+ );
1258
+ }
1259
+
1175
1260
  function getHorizontalBounds(core, bypassMargins) {
1176
1261
  if (bypassMargins) {
1177
1262
  return { left: 0, right: core.pageWidth };
package/core/validate.js CHANGED
@@ -2,7 +2,7 @@ const { hasPlugin } = require("./plugin-registry");
2
2
 
3
3
  const OPERATION_TYPES = new Set([
4
4
  "text", "row", "bullet", "divider", "spacer", "hiddenText",
5
- "block", "group", "section", "quote", "table", "image",
5
+ "block", "group", "section", "quote", "table", "image", "columns",
6
6
  ]);
7
7
 
8
8
  function validateSource(source) {
@@ -108,6 +108,18 @@ function validateOperation(op, path) {
108
108
  return;
109
109
  }
110
110
 
111
+ if (type === "columns") {
112
+ if (!Array.isArray(op.column1)) {
113
+ throw new Error(`${path}: columns requires column1 array`);
114
+ }
115
+ if (!Array.isArray(op.column2)) {
116
+ throw new Error(`${path}: columns requires column2 array`);
117
+ }
118
+ op.column1.forEach((child, i) => validateOperation(child, `${path}.column1[${i}]`));
119
+ op.column2.forEach((child, i) => validateOperation(child, `${path}.column2[${i}]`));
120
+ return;
121
+ }
122
+
111
123
  if (hasPlugin(type)) {
112
124
  return;
113
125
  }
@@ -147,6 +159,8 @@ function collectLabels(node, missing, themeLabels) {
147
159
  if (Array.isArray(ops)) {
148
160
  ops.forEach((n) => collectLabels(n, missing, themeLabels));
149
161
  }
162
+ if (Array.isArray(node.column1)) node.column1.forEach((n) => collectLabels(n, missing, themeLabels));
163
+ if (Array.isArray(node.column2)) node.column2.forEach((n) => collectLabels(n, missing, themeLabels));
150
164
 
151
165
  if (node.label && !themeLabels.has(node.label)) missing.push(node.label);
152
166
  if (node.leftLabel && !themeLabels.has(node.leftLabel)) missing.push(node.leftLabel);
Binary file
Binary file
Binary file