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 +45 -0
- package/README.md +1 -0
- package/core/image-utils.js +112 -0
- package/core/pdf-core.js +8 -0
- package/core/plugin-registry.js +1 -1
- package/core/render-document.js +175 -0
- package/core/validate.js +13 -1
- package/package.json +2 -2
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 = {
|
package/core/plugin-registry.js
CHANGED
|
@@ -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) {
|
package/core/render-document.js
CHANGED
|
@@ -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
|
|
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://
|
|
41
|
+
"homepage": "https://h17.ai/docs/sspdf",
|
|
42
42
|
"bugs": {
|
|
43
43
|
"url": "https://github.com/hugopalma17/sspdf/issues"
|
|
44
44
|
},
|