h17-sspdf 1.0.2 → 1.1.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
@@ -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
 
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
@@ -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);
@@ -0,0 +1,68 @@
1
+ {
2
+ "operations": [
3
+ { "type": "text", "label": "demo.title", "text": "Service Plan Comparison" },
4
+ { "type": "text", "label": "demo.subtitle", "text": "Choose the plan that fits your team. Both plans include SLA, onboarding, and 24/7 support." },
5
+ { "type": "divider", "label": "demo.rule" },
6
+
7
+ {
8
+ "type": "columns",
9
+ "column1": [
10
+ { "type": "text", "label": "demo.col.heading", "text": "Starter" },
11
+ { "type": "text", "label": "demo.col.tagline", "text": "For small teams getting started with automation" },
12
+ { "type": "text", "label": "demo.col.price", "text": "$49 / mo" },
13
+ { "type": "text", "label": "demo.col.price.note", "text": "Per workspace, billed monthly" },
14
+ { "type": "divider", "label": "demo.rule.soft" },
15
+ {
16
+ "type": "table",
17
+ "label": "demo.table.cell",
18
+ "headerLabel": "demo.table.header",
19
+ "columns": [
20
+ { "header": "Feature", "width": "72%", "align": "left" },
21
+ { "header": "Included", "width": "28%", "align": "center" }
22
+ ],
23
+ "rows": [
24
+ ["Users", "Up to 5"],
25
+ ["Projects", "10"],
26
+ ["Storage", "20 GB"],
27
+ ["API calls / day", "5,000"],
28
+ ["Custom integrations", "—"],
29
+ ["SSO / SAML", "—"],
30
+ ["Audit log", "—"],
31
+ ["Priority support", "—"],
32
+ ["SLA uptime", "99.5%"]
33
+ ]
34
+ }
35
+ ],
36
+ "column2": [
37
+ { "type": "text", "label": "demo.col.heading", "text": "Professional" },
38
+ { "type": "text", "label": "demo.col.tagline", "text": "For growing teams that need scale and compliance" },
39
+ { "type": "text", "label": "demo.col.price", "text": "$149 / mo" },
40
+ { "type": "text", "label": "demo.col.price.note", "text": "Per workspace, billed monthly" },
41
+ { "type": "divider", "label": "demo.rule.soft" },
42
+ {
43
+ "type": "table",
44
+ "label": "demo.table.cell",
45
+ "headerLabel": "demo.table.header",
46
+ "columns": [
47
+ { "header": "Feature", "width": "72%", "align": "left" },
48
+ { "header": "Included", "width": "28%", "align": "center" }
49
+ ],
50
+ "rows": [
51
+ ["Users", "Unlimited"],
52
+ ["Projects", "Unlimited"],
53
+ ["Storage", "500 GB"],
54
+ ["API calls / day", "100,000"],
55
+ ["Custom integrations", "Yes"],
56
+ ["SSO / SAML", "Yes"],
57
+ ["Audit log", "90 days"],
58
+ ["Priority support", "Yes"],
59
+ ["SLA uptime", "99.9%"]
60
+ ]
61
+ }
62
+ ]
63
+ },
64
+
65
+ { "type": "text", "label": "demo.footer.note", "text": "Prices shown in USD. Annual billing saves 20%. All plans include a 14-day free trial with no credit card required." },
66
+ { "type": "text", "label": "demo.footer.cta", "text": "Contact sales@example.com to discuss enterprise pricing or volume discounts." }
67
+ ]
68
+ }
@@ -0,0 +1,105 @@
1
+ const table = require("./table");
2
+
3
+ const BLACK = [15, 15, 15];
4
+ const MUTED = [100, 100, 100];
5
+ const WHITE = [255, 255, 255];
6
+ const LIGHT = [245, 245, 245];
7
+ const RULE = [210, 210, 210];
8
+ const ACCENT = [30, 90, 180];
9
+
10
+ module.exports = {
11
+ name: "Two-Column Demo",
12
+
13
+ page: {
14
+ format: "a4",
15
+ orientation: "portrait",
16
+ unit: "mm",
17
+ compress: true,
18
+ marginTopMm: 14,
19
+ marginBottomMm: 14,
20
+ marginLeftMm: 14,
21
+ marginRightMm: 14,
22
+ backgroundColor: WHITE,
23
+ defaultText: {
24
+ fontFamily: "helvetica",
25
+ fontStyle: "normal",
26
+ fontSize: 9,
27
+ color: BLACK,
28
+ lineHeight: 1.4,
29
+ },
30
+ defaultStroke: {
31
+ color: RULE,
32
+ lineWidth: 0.2,
33
+ lineCap: "butt",
34
+ lineJoin: "miter",
35
+ },
36
+ defaultFillColor: WHITE,
37
+ metadata: {
38
+ title: "Service Plan Comparison",
39
+ subject: "sspdf two-column layout demo",
40
+ },
41
+ },
42
+
43
+ layout: {
44
+ columnGutterMm: 6,
45
+ },
46
+
47
+ labels: {
48
+ "demo.title": {
49
+ fontFamily: "helvetica", fontStyle: "bold", fontSize: 18, color: BLACK,
50
+ lineHeight: 1.1, marginBottomMm: 1,
51
+ },
52
+ "demo.subtitle": {
53
+ fontFamily: "helvetica", fontStyle: "normal", fontSize: 10, color: MUTED,
54
+ lineHeight: 1.3, marginBottomMm: 5,
55
+ },
56
+ "demo.rule": {
57
+ color: ACCENT, lineWidth: 0.6, marginBottomMm: 5,
58
+ },
59
+ "demo.rule.soft": {
60
+ color: RULE, lineWidth: 0.2, marginTopMm: 3, marginBottomMm: 3,
61
+ },
62
+
63
+ "demo.col.heading": {
64
+ fontFamily: "helvetica", fontStyle: "bold", fontSize: 11, color: ACCENT,
65
+ lineHeight: 1.15, marginBottomMm: 2,
66
+ },
67
+ "demo.col.tagline": {
68
+ fontFamily: "helvetica", fontStyle: "normal", fontSize: 8, color: MUTED,
69
+ lineHeight: 1.3, marginBottomMm: 3,
70
+ },
71
+ "demo.col.price": {
72
+ fontFamily: "helvetica", fontStyle: "bold", fontSize: 20, color: BLACK,
73
+ lineHeight: 1.1, marginBottomMm: 1,
74
+ },
75
+ "demo.col.price.note": {
76
+ fontFamily: "helvetica", fontStyle: "normal", fontSize: 7, color: MUTED,
77
+ lineHeight: 1.2, marginBottomMm: 3,
78
+ },
79
+
80
+ "demo.table.cell": {
81
+ ...table.cell,
82
+ fontFamily: "helvetica", fontStyle: "normal", fontSize: 8, color: BLACK,
83
+ lineHeight: 1.3, cellPaddingMm: 1.2,
84
+ backgroundColor: WHITE, altRowColor: LIGHT,
85
+ borderColor: RULE,
86
+ borderTopMm: 0, borderBottomMm: 0.15, borderLeftMm: 0, borderRightMm: 0,
87
+ },
88
+ "demo.table.header": {
89
+ ...table.header,
90
+ fontFamily: "helvetica", fontStyle: "bold", fontSize: 8, color: WHITE,
91
+ lineHeight: 1.3, cellPaddingMm: 1.2,
92
+ backgroundColor: ACCENT, borderColor: ACCENT,
93
+ borderTopMm: 0, borderBottomMm: 0, borderLeftMm: 0, borderRightMm: 0,
94
+ },
95
+
96
+ "demo.footer.note": {
97
+ fontFamily: "helvetica", fontStyle: "normal", fontSize: 7.5, color: MUTED,
98
+ lineHeight: 1.4, marginTopMm: 4, align: "center",
99
+ },
100
+ "demo.footer.cta": {
101
+ fontFamily: "helvetica", fontStyle: "bold", fontSize: 9, color: ACCENT,
102
+ lineHeight: 1.3, align: "center",
103
+ },
104
+ },
105
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "h17-sspdf",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "description": "Declarative PDF engine - define layout once, feed it JSON, get consistent PDFs",
5
5
  "main": "index.js",
6
6
  "author": "Hugo Palma",