h17-sspdf 0.1.8 → 0.4.1

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
@@ -494,6 +494,49 @@ Renders a data table with optional header row, per-column alignment, alternating
494
494
 
495
495
  **Style cascade:** Engine defaults, then theme label, then source-level overrides. The source operation can override `altRowColor`, `cellPaddingMm`, and all border properties directly.
496
496
 
497
+ #### `image`
498
+
499
+ Embeds a PNG or JPEG image from a file path. The image and its caption are always kept together on the same page.
500
+
501
+ ```json
502
+ {
503
+ "type": "image",
504
+ "src": "photos/cityscape.jpg",
505
+ "width": "100%",
506
+ "label": "editorial.photo",
507
+ "caption": "Fig. 1 - Downtown skyline at dusk",
508
+ "captionLabel": "editorial.photo.caption"
509
+ }
510
+ ```
511
+
512
+ | Field | Required | Type | Description |
513
+ |---|---|---|---|
514
+ | `src` | yes | string | File path to a PNG or JPEG image (absolute, or relative to CWD) |
515
+ | `width` | no | string | Percentage of content width (e.g., `"100%"`, `"50%"`). Height derived from aspect ratio. |
516
+ | `widthMm` | no | number | Explicit width in mm. If `heightMm` is omitted, height derived from aspect ratio. |
517
+ | `heightMm` | no | number | Explicit height in mm. If both `widthMm` and `heightMm` are set, the image may distort. |
518
+ | `label` | no | string | Theme label for padding and margins around the image |
519
+ | `caption` | no | string | Caption text rendered centered below the image |
520
+ | `captionLabel` | no | string | Theme label for caption styling (default: `label + ".caption"`) |
521
+
522
+ **Size resolution priority:**
523
+
524
+ 1. `widthMm` + `heightMm` both set: exact slot, may distort if aspect ratio differs
525
+ 2. `width: "80%"`: percentage of content area width, height from aspect ratio
526
+ 3. `widthMm` only: fixed width, height from aspect ratio
527
+ 4. `heightMm` only: fixed height, width from aspect ratio
528
+ 5. Nothing set: fills content width, height from aspect ratio
529
+
530
+ When the image is narrower than the content area, it is centered horizontally within the padded bounds.
531
+
532
+ **Caption behavior:** If `caption` is set, the text renders centered below the image. The caption label defaults to `label + ".caption"`. If no `captionLabel` or derived label exists in the theme, the engine applies defaults: same font family as `page.defaultText`, italic, 2pt smaller, centered, with a 1.5mm gap above the caption. Color is inherited from `defaultText` (not hardcoded), so it works on any background. To override the defaults, declare a `captionLabel` in the theme.
533
+
534
+ **Image label properties:** The `label` on an image operation controls spacing around the image block (padding and margins), not typography. Use it to set `paddingTopMm`, `paddingBottomMm`, `paddingLeftMm`, `paddingRightMm`, `marginTopMm`, `marginBottomMm`. If no `label` is set, the image renders with zero padding and margins.
535
+
536
+ **Keep-together:** The image and its caption are always rendered as one unit. If the total height (margins + padding + image + caption) does not fit on the current page, the entire block moves to the next page.
537
+
538
+ **Works in any layout:** The image operation is a core type, not a plugin. It works in any source JSON with any theme. No registration or setup needed.
539
+
497
540
  #### `spacer`
498
541
 
499
542
  Adds vertical space without drawing anything.
@@ -615,6 +658,7 @@ The engine positions content using a cursor that starts at `marginTopMm` and mov
615
658
  | `spacer` | the specified mm/px value |
616
659
  | `hiddenText` | 0 |
617
660
  | `table` | marginTop + headerRowHeight + sum(dataRowHeights) + marginBottom |
661
+ | `image` | marginTop + paddingTop + imageHeightMm + captionHeight + paddingBottom + marginBottom |
618
662
  | `block` | sum of children deltas (+ spaceAfter if defined) |
619
663
 
620
664
  Where:
@@ -1285,6 +1329,7 @@ This applies to all color properties: `color`, `backgroundColor`, `borderColor`,
1285
1329
  | `bullet` | `label`, `text`/`items`/`bullets` | `markerLabel`, `marker`, `textIndentMm` |
1286
1330
  | `divider` | `label` | `x1Mm`, `x2Mm` |
1287
1331
  | `table` | `label`, `columns`, `rows` | `headerLabel`, `xMm`, `maxWidthMm`, border/padding overrides |
1332
+ | `image` | `src` | `width`, `widthMm`, `heightMm`, `label`, `caption`, `captionLabel` |
1288
1333
  | `spacer` | `mm` or `px` or `label` | - |
1289
1334
  | `block` | `children`/`content`/`items` | `label`, `keepTogether`, `spaceAfterMm`/`Px`/`Label` |
1290
1335
  | `section` | `content` | `label`, `keepTogether` |
package/README.md CHANGED
@@ -261,6 +261,7 @@ labels: {
261
261
  | `row` | Left/right pair on one line | `leftLabel`, `rightLabel`, `leftText`, `rightText` |
262
262
  | `bullet` | Marker + wrapped text | `label`, `markerLabel`, `bullets` (array) |
263
263
  | `divider` | Horizontal rule | `label`, `x1Mm`, `x2Mm` |
264
+ | `image` | Embedded PNG/JPEG | `src`, `width` (percentage or mm), `caption` |
264
265
  | `spacer` | Vertical gap | `mm`, `px`, or `label` |
265
266
  | `hiddenText` | Invisible text | `label`, `text` |
266
267
  | `quote` | Blockquote with attribution | `label`, `text`, `attribution` |
@@ -0,0 +1,112 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Read native width/height from PNG or JPEG buffer.
5
+ * Returns { width, height, format } or throws.
6
+ */
7
+ function getImageDimensions(buf) {
8
+ if (!Buffer.isBuffer(buf)) {
9
+ buf = Buffer.from(buf);
10
+ }
11
+
12
+ // PNG: signature 89 50 4E 47, dimensions in IHDR at bytes 16-23
13
+ if (buf.length >= 24 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4E && buf[3] === 0x47) {
14
+ const width = buf.readUInt32BE(16);
15
+ const height = buf.readUInt32BE(20);
16
+ return { width, height, format: "PNG" };
17
+ }
18
+
19
+ // JPEG: signature FF D8
20
+ if (buf.length >= 4 && buf[0] === 0xFF && buf[1] === 0xD8) {
21
+ let offset = 2;
22
+ while (offset < buf.length - 1) {
23
+ if (buf[offset] !== 0xFF) {
24
+ offset++;
25
+ continue;
26
+ }
27
+ const marker = buf[offset + 1];
28
+
29
+ // SOF markers: C0-C3, C5-C7, C9-CB, CD-CF
30
+ if (
31
+ (marker >= 0xC0 && marker <= 0xC3) ||
32
+ (marker >= 0xC5 && marker <= 0xC7) ||
33
+ (marker >= 0xC9 && marker <= 0xCB) ||
34
+ (marker >= 0xCD && marker <= 0xCF)
35
+ ) {
36
+ // offset+2 = length (2 bytes), offset+4 = precision (1 byte)
37
+ // offset+5 = height (2 bytes), offset+7 = width (2 bytes)
38
+ if (offset + 8 < buf.length) {
39
+ const height = buf.readUInt16BE(offset + 5);
40
+ const width = buf.readUInt16BE(offset + 7);
41
+ return { width, height, format: "JPEG" };
42
+ }
43
+ }
44
+
45
+ // Skip marker segment
46
+ if (marker === 0xD0 || marker === 0xD1 || marker === 0xD2 || marker === 0xD3 ||
47
+ marker === 0xD4 || marker === 0xD5 || marker === 0xD6 || marker === 0xD7 ||
48
+ marker === 0xD8 || marker === 0xD9 || marker === 0x01) {
49
+ offset += 2;
50
+ continue;
51
+ }
52
+
53
+ if (offset + 3 < buf.length) {
54
+ const segLen = buf.readUInt16BE(offset + 2);
55
+ offset += 2 + segLen;
56
+ } else {
57
+ break;
58
+ }
59
+ }
60
+ throw new Error("JPEG file: could not find SOF marker for dimensions");
61
+ }
62
+
63
+ throw new Error("Unsupported image format. Only PNG and JPEG are supported.");
64
+ }
65
+
66
+ /**
67
+ * Resolve display dimensions from source operation and content bounds.
68
+ *
69
+ * @param {object} op - Operation with width/widthMm/heightMm
70
+ * @param {number} nativeW - Native image width in pixels
71
+ * @param {number} nativeH - Native image height in pixels
72
+ * @param {number} contentWidthMm - Available content width in mm
73
+ * @returns {{ widthMm: number, heightMm: number }}
74
+ */
75
+ function resolveImageSize(op, nativeW, nativeH, contentWidthMm) {
76
+ const ratio = nativeH / nativeW;
77
+
78
+ // Explicit mm for both dimensions (may distort)
79
+ if (op.widthMm !== undefined && op.heightMm !== undefined) {
80
+ return { widthMm: Number(op.widthMm), heightMm: Number(op.heightMm) };
81
+ }
82
+
83
+ // Percentage width, derive height from aspect ratio
84
+ if (op.width !== undefined) {
85
+ const pctStr = String(op.width);
86
+ let widthMm;
87
+ if (pctStr.endsWith("%")) {
88
+ const pct = parseFloat(pctStr) / 100;
89
+ widthMm = contentWidthMm * pct;
90
+ } else {
91
+ widthMm = parseFloat(pctStr);
92
+ }
93
+ return { widthMm, heightMm: widthMm * ratio };
94
+ }
95
+
96
+ // Explicit widthMm only, derive height
97
+ if (op.widthMm !== undefined) {
98
+ const widthMm = Number(op.widthMm);
99
+ return { widthMm, heightMm: widthMm * ratio };
100
+ }
101
+
102
+ // Explicit heightMm only, derive width
103
+ if (op.heightMm !== undefined) {
104
+ const heightMm = Number(op.heightMm);
105
+ return { widthMm: heightMm / ratio, heightMm };
106
+ }
107
+
108
+ // Default: fill content width
109
+ return { widthMm: contentWidthMm, heightMm: contentWidthMm * ratio };
110
+ }
111
+
112
+ module.exports = { getImageDimensions, resolveImageSize };
package/core/pdf-core.js CHANGED
@@ -614,6 +614,10 @@ class PDFCore {
614
614
  ? Number(payload.y) + marginTopMm
615
615
  : this.cursorY + marginTopMm;
616
616
 
617
+ // Save/restore graphics state around addImage to prevent jsPDF font leak
618
+ if (typeof this.doc.saveGraphicsState === "function") {
619
+ this.doc.saveGraphicsState();
620
+ }
617
621
  this.doc.addImage(
618
622
  payload.data,
619
623
  payload.format || "PNG",
@@ -622,6 +626,10 @@ class PDFCore {
622
626
  widthMm,
623
627
  heightMm
624
628
  );
629
+ if (typeof this.doc.restoreGraphicsState === "function") {
630
+ this.doc.restoreGraphicsState();
631
+ }
632
+ this.applyDefaultRenderState();
625
633
 
626
634
  const endY = drawY + heightMm;
627
635
  this.lastDrawnBounds = {
@@ -2,7 +2,7 @@ const _plugins = new Map();
2
2
 
3
3
  const BUILT_IN_TYPES = new Set([
4
4
  "text", "row", "bullet", "divider", "spacer", "hiddenText",
5
- "block", "section", "group", "quote", "table",
5
+ "block", "section", "group", "quote", "table", "image",
6
6
  ]);
7
7
 
8
8
  function registerPlugin(type, handler) {
@@ -1,8 +1,11 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
1
3
  const { PDFCore, getStyleMarginsMm, getTextPaddingMm, applyTextTransform } = require("./pdf-core");
2
4
  const { pxToMm, resolveLineHeightMm } = require("./units");
3
5
  const { registerThemeFonts } = require("./font-registry");
4
6
  const { getPlugin, hasPlugin } = require("./plugin-registry");
5
7
  const { validateSource, validateTheme, validateSourceAgainstTheme } = require("./validate");
8
+ const { getImageDimensions, resolveImageSize } = require("./image-utils");
6
9
 
7
10
  /**
8
11
  * Render a document by executing labeled operations.
@@ -334,6 +337,7 @@ function isOperationType(type) {
334
337
  || type === "spacer"
335
338
  || type === "hiddenText"
336
339
  || type === "table"
340
+ || type === "image"
337
341
  || hasPlugin(type);
338
342
  }
339
343
 
@@ -731,6 +735,138 @@ function executeOperation(ctx) {
731
735
  return;
732
736
  }
733
737
 
738
+ if (operation.type === "image") {
739
+ const bounds = getHorizontalBounds(core, templateBypassMargins);
740
+ const contentWidthMm = bounds.right - bounds.left;
741
+
742
+ // Read image file
743
+ const srcPath = operation.src;
744
+ if (!srcPath) {
745
+ throw new Error(`Operation ${index} (image) missing "src" field`);
746
+ }
747
+ const resolvedPath = path.isAbsolute(srcPath) ? srcPath : path.resolve(process.cwd(), srcPath);
748
+ let buf;
749
+ try {
750
+ buf = fs.readFileSync(resolvedPath);
751
+ } catch (err) {
752
+ throw new Error(`Operation ${index} (image) could not read file "${resolvedPath}": ${err.message}`);
753
+ }
754
+ if (!buf || buf.length === 0) {
755
+ throw new Error(`Operation ${index} (image) file is empty: "${resolvedPath}"`);
756
+ }
757
+ let imgInfo;
758
+ try {
759
+ imgInfo = getImageDimensions(buf);
760
+ } catch (err) {
761
+ throw new Error(`Operation ${index} (image) could not read dimensions from "${resolvedPath}": ${err.message}`);
762
+ }
763
+ if (!imgInfo || !imgInfo.width || !imgInfo.height) {
764
+ throw new Error(`Operation ${index} (image) invalid dimensions from "${resolvedPath}"`);
765
+ }
766
+ const { widthMm, heightMm } = resolveImageSize(operation, imgInfo.width, imgInfo.height, contentWidthMm);
767
+ if (!widthMm || widthMm <= 0 || !heightMm || heightMm <= 0) {
768
+ throw new Error(`Operation ${index} (image) resolved to invalid size: ${widthMm}x${heightMm}mm`);
769
+ }
770
+
771
+ // Resolve padding from label
772
+ const style = operation.label
773
+ ? resolveLabelStyle(theme, operation.label, operation, index, "label", true) || {}
774
+ : {};
775
+ const padding = getTextPaddingMm(style);
776
+ const marginTop = getStyleMarginsMm(style).top;
777
+ const marginBottom = getStyleMarginsMm(style).bottom;
778
+
779
+ // Caption style and height
780
+ let captionLines = [];
781
+ let captionStyle = null;
782
+ let captionLineHeightMm = 0;
783
+ let captionHeight = 0;
784
+ if (operation.caption) {
785
+ const capLabel = operation.captionLabel || (operation.label ? operation.label + ".caption" : null);
786
+ captionStyle = capLabel
787
+ ? resolveLabelStyle(theme, capLabel, operation, index, "captionLabel", true)
788
+ : null;
789
+ if (!captionStyle) {
790
+ // Fall back to theme default text: same family, italic, smaller, gray, centered, with gap
791
+ const dt = theme.page.defaultText || {};
792
+ captionStyle = Object.assign({}, dt, {
793
+ fontStyle: "italic",
794
+ fontSize: Math.max((Number(dt.fontSize) || 10) - 2, 7),
795
+ align: "center",
796
+ marginTopMm: 1.5,
797
+ });
798
+ }
799
+ if (!captionStyle.align) {
800
+ captionStyle.align = "center";
801
+ }
802
+ captionLineHeightMm = resolveLineHeightMm(
803
+ Number(captionStyle.fontSize) || 10,
804
+ captionStyle.lineHeight || 1.2
805
+ );
806
+ const captionWidth = widthMm - padding.left - padding.right;
807
+ captionLines = core.measureWrappedLines(
808
+ applyTextTransform(operation.caption, captionStyle.textTransform),
809
+ captionWidth > 0 ? captionWidth : widthMm,
810
+ captionStyle
811
+ );
812
+ const captionMargins = getStyleMarginsMm(captionStyle);
813
+ captionHeight = captionMargins.top + (Math.max(captionLines.length, 1) * captionLineHeightMm) + captionMargins.bottom;
814
+ }
815
+
816
+ // Total block height
817
+ const totalHeight = marginTop + padding.top + heightMm + captionHeight + padding.bottom + marginBottom;
818
+
819
+ if (!templateMode) {
820
+ core.ensureSpace(totalHeight);
821
+ }
822
+
823
+ const startY = core.cursorY + marginTop;
824
+ const innerLeft = bounds.left + padding.left;
825
+ const innerWidth = contentWidthMm - padding.left - padding.right;
826
+
827
+ // Center image within padded area
828
+ const imgX = innerLeft + (innerWidth - widthMm) / 2;
829
+ const imgY = startY + padding.top;
830
+
831
+ // Draw image
832
+ const base64 = buf.toString("base64");
833
+ core.drawImage({
834
+ data: base64,
835
+ format: imgInfo.format,
836
+ x: imgX,
837
+ y: imgY,
838
+ widthMm,
839
+ heightMm,
840
+ style: {},
841
+ advance: false,
842
+ allowPageBreak: false,
843
+ });
844
+
845
+ // Reset jsPDF font state after addImage (jsPDF addImage corrupts the
846
+ // font state in the PDF content stream without updating its internal tracker,
847
+ // so subsequent setFont calls may be silently ignored)
848
+ core.applyDefaultRenderState();
849
+
850
+ // Draw caption at the correct Y by setting cursor before drawText
851
+ if (operation.caption && captionStyle && captionLines.length > 0) {
852
+ core.cursorY = imgY + heightMm;
853
+ const captionX = innerLeft;
854
+ const captionWidth = innerWidth > 0 ? innerWidth : widthMm;
855
+ core.drawText({
856
+ text: operation.caption,
857
+ style: captionStyle,
858
+ x: captionX,
859
+ maxWidth: captionWidth,
860
+ advance: true,
861
+ allowPageBreak: false,
862
+ });
863
+ }
864
+
865
+ // Advance cursor past the whole block
866
+ core.cursorY = startY + padding.top + heightMm + captionHeight + padding.bottom + marginBottom;
867
+ return;
868
+ }
869
+
734
870
  const plugin = getPlugin(operation.type);
735
871
  if (plugin) {
736
872
  const bounds = getHorizontalBounds(core, templateBypassMargins);
@@ -935,6 +1071,45 @@ function estimateOperationHeight(ctx) {
935
1071
  return total;
936
1072
  }
937
1073
 
1074
+ if (operation.type === "image") {
1075
+ const style = operation.label
1076
+ ? resolveLabelStyle(theme, operation.label, operation, index)
1077
+ : {};
1078
+ const margins = getStyleMarginsMm(style);
1079
+ const padding = getTextPaddingMm(style);
1080
+ const contentWidthMm = core.pageWidth - core.marginLeftMm - core.marginRightMm;
1081
+
1082
+ // Read image to get dimensions for height calculation
1083
+ let imgHeightMm = 80; // fallback
1084
+ if (operation.src) {
1085
+ try {
1086
+ const resolvedPath = path.isAbsolute(operation.src) ? operation.src : path.resolve(process.cwd(), operation.src);
1087
+ const buf = fs.readFileSync(resolvedPath);
1088
+ const imgInfo = getImageDimensions(buf);
1089
+ const resolved = resolveImageSize(operation, imgInfo.width, imgInfo.height, contentWidthMm - padding.left - padding.right);
1090
+ imgHeightMm = resolved.heightMm;
1091
+ } catch (e) {
1092
+ // If file can't be read during height calc, use fallback
1093
+ }
1094
+ } else if (operation.heightMm !== undefined) {
1095
+ imgHeightMm = Number(operation.heightMm);
1096
+ }
1097
+
1098
+ let captionHeight = 0;
1099
+ if (operation.caption) {
1100
+ const capLabel = operation.captionLabel || (operation.label ? operation.label + ".caption" : null);
1101
+ const captionStyle = capLabel
1102
+ ? resolveLabelStyle(theme, capLabel, operation, index, "captionLabel", true)
1103
+ : Object.assign({}, theme.page.defaultText);
1104
+ const capLh = resolveLineHeightMm(Number(captionStyle.fontSize) || 10, captionStyle.lineHeight || 1.2);
1105
+ const capMargins = getStyleMarginsMm(captionStyle);
1106
+ // Rough: 1 line for caption height calc
1107
+ captionHeight = capMargins.top + capLh + capMargins.bottom;
1108
+ }
1109
+
1110
+ return margins.top + padding.top + imgHeightMm + captionHeight + padding.bottom + margins.bottom;
1111
+ }
1112
+
938
1113
  const plugin = getPlugin(operation.type);
939
1114
  if (plugin) {
940
1115
  if (typeof plugin.estimateHeight === "function") {
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",
5
+ "block", "group", "section", "quote", "table", "image",
6
6
  ]);
7
7
 
8
8
  function validateSource(source) {
@@ -94,6 +94,11 @@ function validateOperation(op, path) {
94
94
  return;
95
95
  }
96
96
 
97
+ if (type === "image") {
98
+ if (!op.src) throw new Error(`${path}: image requires src`);
99
+ return;
100
+ }
101
+
97
102
  if (type === "block" || type === "group" || type === "section") {
98
103
  const children = op.children || op.content || op.items || op.sections;
99
104
  if (!Array.isArray(children)) {
@@ -150,10 +155,17 @@ function collectLabels(node, missing, themeLabels) {
150
155
  if (node.headerLabel && !themeLabels.has(node.headerLabel)) missing.push(node.headerLabel);
151
156
  if (node.spaceAfterLabel && !themeLabels.has(node.spaceAfterLabel)) missing.push(node.spaceAfterLabel);
152
157
 
158
+ if (node.captionLabel && !themeLabels.has(node.captionLabel)) missing.push(node.captionLabel);
159
+
153
160
  if (node.type === "quote" && node.attribution) {
154
161
  const attrLabel = node.attributionLabel || (node.label + ".attribution");
155
162
  if (!themeLabels.has(attrLabel)) missing.push(attrLabel);
156
163
  }
164
+
165
+ if (node.type === "image" && node.caption && !node.captionLabel && node.label) {
166
+ const capLabel = node.label + ".caption";
167
+ if (!themeLabels.has(capLabel)) missing.push(capLabel);
168
+ }
157
169
  }
158
170
 
159
171
  module.exports = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "h17-sspdf",
3
- "version": "0.1.8",
3
+ "version": "0.4.1",
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",
@@ -38,7 +38,7 @@
38
38
  "type": "git",
39
39
  "url": "git+ssh://git@github.com/hugopalma17/sspdf.git"
40
40
  },
41
- "homepage": "https://github.com/hugopalma17/sspdf#readme",
41
+ "homepage": "https://h17.ai/docs/sspdf",
42
42
  "bugs": {
43
43
  "url": "https://github.com/hugopalma17/sspdf/issues"
44
44
  },