h17-sspdf 0.4.1 → 1.0.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
@@ -30,9 +30,11 @@ Controls page geometry, background, and baseline rendering state.
30
30
  ```js
31
31
  page: {
32
32
  // Page geometry
33
- format: "a4", // only "a4" is supported
33
+ format: "a4", // named format: "a4", "letter", etc.
34
34
  orientation: "portrait", // "portrait" or "landscape"
35
35
  unit: "mm", // only "mm" is supported
36
+ pageWidthMm: 338, // custom width in mm (overrides format)
37
+ pageHeightMm: 190, // custom height in mm (overrides format)
36
38
  compress: true, // PDF compression (default true)
37
39
 
38
40
  // Margins - individual margins override the shared `margin` value
@@ -76,7 +78,9 @@ page: {
76
78
  }
77
79
  ```
78
80
 
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.
81
+ The content area runs from `marginTop` to `pageHeight - marginBottom` vertically, and `marginLeft` to `pageWidth - marginRight` horizontally. On A4 portrait, the page is 210 x 297 mm.
82
+
83
+ When `pageWidthMm` and `pageHeightMm` are both set, they override the `format` field and create a page with those exact dimensions. This is useful for non-standard formats like 16:9 presentations (e.g. 338 x 190 mm). All layout math -- margins, content area, pagination -- adapts automatically.
80
84
 
81
85
  ### `labels` (required)
82
86
 
@@ -235,6 +239,7 @@ Shared layout defaults read by operation handlers.
235
239
  ```js
236
240
  layout: {
237
241
  bulletIndentMm: 4.5, // text indent after bullet marker (default: 4)
242
+ chartAlign: "center", // chart horizontal alignment: "left" (default) or "center"
238
243
  }
239
244
  ```
240
245
 
@@ -553,6 +558,16 @@ Three forms (use exactly one):
553
558
  | `px` | number | Fixed space in CSS px |
554
559
  | `label` | string | Theme label with `spaceMm` or `spacePx` |
555
560
 
561
+ #### `pageBreak`
562
+
563
+ Forces a new page. The cursor resets to the top content area. Page templates (headers/footers) are applied to the new page automatically.
564
+
565
+ ```json
566
+ { "type": "pageBreak" }
567
+ ```
568
+
569
+ No fields required. Useful for presentation-style layouts where each section should start on its own page.
570
+
556
571
  #### `hiddenText`
557
572
 
558
573
  Renders text in the background color so it's invisible but present in the PDF text layer (for ATS keyword injection).
@@ -1714,11 +1729,12 @@ main();
1714
1729
  | `chartType` | yes | string | Chart.js type: `"bar"`, `"line"`, `"doughnut"`, etc. |
1715
1730
  | `data` | yes | object | Chart.js `data` config (`labels` + `datasets`) |
1716
1731
  | `widthMm` | no | number | Width in the PDF in mm (default: content area width) |
1717
- | `heightMm` | no | number | Height in the PDF in mm (default: 80) |
1732
+ | `heightMm` | no | number or `"fill"` | Height in mm (default: 80). `"fill"` uses remaining page space. |
1718
1733
  | `canvasWidth` | no | number | Canvas render width in pixels (default: 1600) |
1719
1734
  | `canvasHeight` | no | number | Canvas render height in pixels (default: 800) |
1720
1735
  | `options` | no | object | Chart.js `options` object. `responsive: false` and `animation: false` are injected automatically. |
1721
1736
  | `xMm` | no | number | Left edge in mm (default: content left margin) |
1737
+ | `align` | no | string | `"left"` (default) or `"center"`. Can also be set globally via `layout.chartAlign` in the theme. |
1722
1738
 
1723
1739
  ### Resolution
1724
1740
 
package/README.md CHANGED
@@ -263,6 +263,7 @@ labels: {
263
263
  | `divider` | Horizontal rule | `label`, `x1Mm`, `x2Mm` |
264
264
  | `image` | Embedded PNG/JPEG | `src`, `width` (percentage or mm), `caption` |
265
265
  | `spacer` | Vertical gap | `mm`, `px`, or `label` |
266
+ | `pageBreak` | Force new page | (none) |
266
267
  | `hiddenText` | Invisible text | `label`, `text` |
267
268
  | `quote` | Blockquote with attribution | `label`, `text`, `attribution` |
268
269
  | `block` | Group children, optional background + border | `children`, `keepTogether` |
@@ -466,7 +467,7 @@ Claude Code skills for generating PDFs and themes are available in the `skills/`
466
467
 
467
468
  ## Constraints
468
469
 
469
- - A4 only
470
+ - Page format defaults to A4; custom dimensions supported via `pageWidthMm`/`pageHeightMm` (e.g. 16:9 presentations)
470
471
  - Single-line `row` cells, no multi-line column pairs
471
472
  - `{{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
472
473
  - Charts require the `canvas` npm package (native C++ addon) for server-side rendering; everything else is zero native dependencies
package/core/pdf-core.js CHANGED
@@ -77,6 +77,8 @@ class PDFCore {
77
77
  * @param {number} [theme.page.margin]
78
78
  * @param {string} [theme.page.format]
79
79
  * @param {string} [theme.page.orientation]
80
+ * @param {number} [theme.page.pageWidthMm]
81
+ * @param {number} [theme.page.pageHeightMm]
80
82
  * @param {string} [theme.page.unit]
81
83
  * @param {boolean} [theme.page.compress]
82
84
  * @param {number[]} [theme.page.backgroundColor]
@@ -102,11 +104,13 @@ class PDFCore {
102
104
  this.margin = this.marginLeftMm;
103
105
  this.headerHeightMm = Number(this.page.headerHeightMm) || 0;
104
106
  this.footerHeightMm = Number(this.page.footerHeightMm) || 0;
105
- this.format = String(this.page.format || "a4").toLowerCase();
107
+ const customW = Number(this.page.pageWidthMm) || 0;
108
+ const customH = Number(this.page.pageHeightMm) || 0;
109
+ const hasCustomDimensions = customW > 0 && customH > 0;
106
110
 
107
- if (this.format !== "a4") {
108
- throw new Error(`Unsupported page format "${this.page.format}". Only A4 is supported.`);
109
- }
111
+ this.format = hasCustomDimensions
112
+ ? [customW, customH]
113
+ : String(this.page.format || "a4").toLowerCase();
110
114
 
111
115
  this.doc = new jsPDF({
112
116
  orientation: this.page.orientation || "portrait",
@@ -119,6 +123,8 @@ class PDFCore {
119
123
  this.pageHeight = this.doc.internal.pageSize.getHeight();
120
124
  this.backgroundColor = this._resolveColor(this.page.backgroundColor);
121
125
  this.lastDrawnBounds = null;
126
+ this.documentStateDepth = 0;
127
+ this.hasDeferredInitialRenderState = Array.isArray(theme.customFonts) && theme.customFonts.length > 0;
122
128
  this.defaultRenderState = this._buildDefaultRenderState(this.page);
123
129
  this.contentTopY = this.marginTopMm + this.headerHeightMm;
124
130
  this.contentBottomY = this.pageHeight - this.marginBottomMm - this.footerHeightMm;
@@ -134,7 +140,12 @@ class PDFCore {
134
140
  });
135
141
 
136
142
  this.paintBackground();
137
- this.applyDefaultRenderState();
143
+ // Custom fonts are registered by renderDocument() after core construction.
144
+ // Deferring the first setFont() avoids jsPDF warnings when page.defaultText
145
+ // uses a custom family that is not yet in the font map.
146
+ if (!this.hasDeferredInitialRenderState) {
147
+ this.applyDefaultRenderState();
148
+ }
138
149
  }
139
150
 
140
151
  /**
@@ -174,9 +185,11 @@ class PDFCore {
174
185
  * Force a new page and reset cursor to top margin.
175
186
  */
176
187
  addPage() {
188
+ const reopenedStateDepth = this._closeDocumentStatesForPageBreak();
177
189
  this.doc.addPage();
178
190
  this.paintBackground();
179
191
  this.applyDefaultRenderState();
192
+ this._reopenDocumentStatesAfterPageBreak(reopenedStateDepth);
180
193
  this.cursorY = this.contentTopY;
181
194
  }
182
195
 
@@ -194,6 +207,7 @@ class PDFCore {
194
207
  && typeof this.doc.restoreGraphicsState === "function";
195
208
  if (canSaveGraphicsState) {
196
209
  this.doc.saveGraphicsState();
210
+ this.documentStateDepth += 1;
197
211
  }
198
212
 
199
213
  try {
@@ -201,6 +215,7 @@ class PDFCore {
201
215
  } finally {
202
216
  if (canSaveGraphicsState) {
203
217
  this.doc.restoreGraphicsState();
218
+ this.documentStateDepth = Math.max(0, this.documentStateDepth - 1);
204
219
  }
205
220
  this.applyDefaultRenderState();
206
221
  }
@@ -494,11 +509,21 @@ class PDFCore {
494
509
  const baseline = y + baselineOffsetMm;
495
510
 
496
511
  if (markerStyle.shape) {
497
- // Vector shape marker: renders via core/shapes.js, no text encoding needed
512
+ // Vector shape marker: renders via core/shapes.js, no text encoding needed.
513
+ // Wrapped in saveGraphicsState/restoreGraphicsState to isolate draw state
514
+ // mutations (setLineCap, setFillColor, etc.) from the main content stream.
515
+ // Without this, accumulated state operators can cause print rendering issues
516
+ // where printer RIPs interpret the stacked state differently than screen viewers.
498
517
  const { renderShape, getShapeWidth } = require("./shapes");
499
518
  const shapeColor = markerStyle.shapeColor || markerStyle.color || [0, 0, 0];
500
519
  const shapeSize = markerStyle.shapeSize || 1;
520
+ if (typeof this.doc.saveGraphicsState === "function") {
521
+ this.doc.saveGraphicsState();
522
+ }
501
523
  renderShape(markerStyle.shape, this.doc, x, baseline, shapeColor, shapeSize, textFontSize);
524
+ if (typeof this.doc.restoreGraphicsState === "function") {
525
+ this.doc.restoreGraphicsState();
526
+ }
502
527
  this.applyDefaultRenderState();
503
528
  } else {
504
529
  // Text-based marker (existing behavior)
@@ -1066,6 +1091,29 @@ class PDFCore {
1066
1091
  fillColor: this._resolveColor(page.defaultFillColor),
1067
1092
  };
1068
1093
  }
1094
+
1095
+ _closeDocumentStatesForPageBreak() {
1096
+ if (this.documentStateDepth <= 0) {
1097
+ return 0;
1098
+ }
1099
+
1100
+ for (let i = 0; i < this.documentStateDepth; i += 1) {
1101
+ this.doc.restoreGraphicsState();
1102
+ }
1103
+
1104
+ return this.documentStateDepth;
1105
+ }
1106
+
1107
+ _reopenDocumentStatesAfterPageBreak(depth) {
1108
+ const count = Number(depth) || 0;
1109
+ if (count <= 0) {
1110
+ return;
1111
+ }
1112
+
1113
+ for (let i = 0; i < count; i += 1) {
1114
+ this.doc.saveGraphicsState();
1115
+ }
1116
+ }
1069
1117
  }
1070
1118
 
1071
1119
  module.exports = {
@@ -132,14 +132,30 @@ module.exports = {
132
132
  );
133
133
  }
134
134
 
135
- const widthMm = operation.widthMm || (bounds.right - bounds.left);
136
- const heightMm = operation.heightMm || 80;
137
- const x = operation.xMm !== undefined ? operation.xMm : bounds.left;
135
+ const { theme } = ctx;
136
+ const contentWidth = bounds.right - bounds.left;
137
+ const widthMm = operation.widthMm || contentWidth;
138
+ const heightMm = operation.heightMm === "fill"
139
+ ? Math.max(0, core.contentBottomY - core.getCursorY())
140
+ : (operation.heightMm || 80);
141
+ const align = operation.align || (theme && theme.layout && theme.layout.chartAlign) || "left";
142
+
143
+ let x;
144
+ if (operation.xMm !== undefined) {
145
+ x = operation.xMm;
146
+ } else if (align === "center") {
147
+ x = bounds.left + (contentWidth - widthMm) / 2;
148
+ } else {
149
+ x = bounds.left;
150
+ }
138
151
 
139
152
  core.drawImage({ data: operation._buf, format: 'PNG', x, widthMm, heightMm });
140
153
  },
141
154
 
142
155
  estimateHeight(ctx) {
156
+ if (ctx.operation.heightMm === "fill") {
157
+ return ctx.core.contentBottomY - ctx.core.getCursorY();
158
+ }
143
159
  return (ctx.operation.heightMm || 80) + 4;
144
160
  },
145
161
 
@@ -47,6 +47,7 @@ function renderDocument(input) {
47
47
 
48
48
  const core = new PDFCore(runtimeTheme);
49
49
  registerThemeFonts(core, runtimeTheme);
50
+ core.applyDefaultRenderState();
50
51
 
51
52
  installPageTemplates(core, runtimeTheme, built.pageTemplates);
52
53
 
@@ -221,17 +222,21 @@ function executeOperations(ctx) {
221
222
  }
222
223
  }
223
224
 
224
- core.withDocumentState(() => {
225
- executeOperation({
226
- core,
227
- theme,
228
- operation,
229
- index,
230
- templateMode,
231
- templateBypassMargins,
232
- insideContainer,
225
+ if (operation.type === "pageBreak") {
226
+ core.addPage();
227
+ } else {
228
+ core.withDocumentState(() => {
229
+ executeOperation({
230
+ core,
231
+ theme,
232
+ operation,
233
+ index,
234
+ templateMode,
235
+ templateBypassMargins,
236
+ insideContainer,
237
+ });
233
238
  });
234
- });
239
+ }
235
240
  }
236
241
  }
237
242
 
@@ -335,6 +340,7 @@ function isOperationType(type) {
335
340
  || type === "bullet"
336
341
  || type === "divider"
337
342
  || type === "spacer"
343
+ || type === "pageBreak"
338
344
  || type === "hiddenText"
339
345
  || type === "table"
340
346
  || type === "image"
@@ -1026,6 +1032,10 @@ function estimateOperationHeight(ctx) {
1026
1032
  throw new Error(`Spacer operation at index ${index} must provide mm, px, or label with spaceMm/spacePx`);
1027
1033
  }
1028
1034
 
1035
+ if (operation.type === "pageBreak") {
1036
+ return 0;
1037
+ }
1038
+
1029
1039
  if (operation.type === "hiddenText") {
1030
1040
  return 0;
1031
1041
  }
Binary file