h17-sspdf 1.3.1 → 1.3.2
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/README.md +13 -1
- package/core/image-utils.js +68 -1
- package/core/pdf-core.js +6 -3
- package/core/render-document.js +8 -37
- package/core/shapes.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -293,7 +293,7 @@ Any operation accepts `xMm` and `maxWidthMm` to override the theme margins for t
|
|
|
293
293
|
fontSize: 10, // pt
|
|
294
294
|
color: [0, 0, 0], // RGB
|
|
295
295
|
lineHeight: 1.4, // multiplier
|
|
296
|
-
textTransform: '
|
|
296
|
+
textTransform: 'upper', // 'upper' | 'lower' | undefined
|
|
297
297
|
|
|
298
298
|
// Spacing
|
|
299
299
|
marginTopMm: 0,
|
|
@@ -468,6 +468,18 @@ Claude Code skills for generating PDFs and themes are available in the `skills/`
|
|
|
468
468
|
|
|
469
469
|
---
|
|
470
470
|
|
|
471
|
+
## Security model
|
|
472
|
+
|
|
473
|
+
SuperSimplePDF turns data into a document. The split between data and code matters for how you treat each input.
|
|
474
|
+
|
|
475
|
+
- Source JSON is data. You can generate it from untrusted input. The engine reads it as a description of what to render, never as code.
|
|
476
|
+
- Theme files are code. A `.js` theme is loaded with `require`, so it runs with your process privileges. Treat a theme like any other module you import. Do not load theme files you do not trust. Built-in themes referenced by name are safe.
|
|
477
|
+
- Image paths are contained. The `image` operation reads files from disk via `src`. To stop an untrusted source document from reading arbitrary files into a PDF, `src` must be a relative path that stays inside the working directory. Absolute paths, parent directory traversal with `..`, and null bytes are rejected.
|
|
478
|
+
|
|
479
|
+
The engine has no network access and no runtime dependencies in the base install, so a source document cannot trigger an outbound request. The optional chart plugin adds the `canvas` native module, which renders locally and does not phone home.
|
|
480
|
+
|
|
481
|
+
If you find a security issue, see SECURITY.md.
|
|
482
|
+
|
|
471
483
|
## Constraints
|
|
472
484
|
|
|
473
485
|
- Page format defaults to A4; custom dimensions supported via `pageWidthMm`/`pageHeightMm` (e.g. 16:9 presentations)
|
package/core/image-utils.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
|
|
6
|
+
const MAX_IMAGE_SIZE_BYTES = 50 * 1024 * 1024;
|
|
7
|
+
|
|
3
8
|
/**
|
|
4
9
|
* Read native width/height from PNG or JPEG buffer.
|
|
5
10
|
* Returns { width, height, format } or throws.
|
|
@@ -63,6 +68,62 @@ function getImageDimensions(buf) {
|
|
|
63
68
|
throw new Error("Unsupported image format. Only PNG and JPEG are supported.");
|
|
64
69
|
}
|
|
65
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Resolve and validate an image `src` path.
|
|
73
|
+
* Only relative paths inside the current working directory are allowed.
|
|
74
|
+
* Returns the absolute resolved path.
|
|
75
|
+
*
|
|
76
|
+
* @param {string} src - Relative path from source JSON
|
|
77
|
+
* @param {string} [index] - Operation index for error messages
|
|
78
|
+
* @returns {string}
|
|
79
|
+
*/
|
|
80
|
+
function resolveImagePath(src, index) {
|
|
81
|
+
const prefix = index ? `Operation ${index} (image) ` : "image ";
|
|
82
|
+
if (!src || typeof src !== "string") {
|
|
83
|
+
throw new Error(`${prefix}missing "src" field`);
|
|
84
|
+
}
|
|
85
|
+
if (path.isAbsolute(src) || src.includes("\0")) {
|
|
86
|
+
throw new Error(`${prefix}"src" must be a relative path: "${src}"`);
|
|
87
|
+
}
|
|
88
|
+
const imageBaseDir = path.resolve(process.cwd());
|
|
89
|
+
const resolvedPath = path.resolve(imageBaseDir, src);
|
|
90
|
+
if (resolvedPath !== imageBaseDir && !resolvedPath.startsWith(imageBaseDir + path.sep)) {
|
|
91
|
+
throw new Error(`${prefix}"src" escapes the working directory: "${src}"`);
|
|
92
|
+
}
|
|
93
|
+
return resolvedPath;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Read an image file with size limits.
|
|
98
|
+
*
|
|
99
|
+
* @param {string} resolvedPath
|
|
100
|
+
* @param {string} [index]
|
|
101
|
+
* @param {number} [maxSizeBytes]
|
|
102
|
+
* @returns {Buffer}
|
|
103
|
+
*/
|
|
104
|
+
function readImageFile(resolvedPath, index, maxSizeBytes = MAX_IMAGE_SIZE_BYTES) {
|
|
105
|
+
const prefix = index ? `Operation ${index} (image) ` : "image ";
|
|
106
|
+
let stats;
|
|
107
|
+
try {
|
|
108
|
+
stats = fs.statSync(resolvedPath);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
throw new Error(`${prefix}could not stat file "${resolvedPath}": ${err.message}`);
|
|
111
|
+
}
|
|
112
|
+
if (stats.size > maxSizeBytes) {
|
|
113
|
+
throw new Error(`${prefix}file exceeds ${maxSizeBytes} bytes: "${resolvedPath}"`);
|
|
114
|
+
}
|
|
115
|
+
let buf;
|
|
116
|
+
try {
|
|
117
|
+
buf = fs.readFileSync(resolvedPath);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
throw new Error(`${prefix}could not read file "${resolvedPath}": ${err.message}`);
|
|
120
|
+
}
|
|
121
|
+
if (!buf || buf.length === 0) {
|
|
122
|
+
throw new Error(`${prefix}file is empty: "${resolvedPath}"`);
|
|
123
|
+
}
|
|
124
|
+
return buf;
|
|
125
|
+
}
|
|
126
|
+
|
|
66
127
|
/**
|
|
67
128
|
* Resolve display dimensions from source operation and content bounds.
|
|
68
129
|
*
|
|
@@ -109,4 +170,10 @@ function resolveImageSize(op, nativeW, nativeH, contentWidthMm) {
|
|
|
109
170
|
return { widthMm: contentWidthMm, heightMm: contentWidthMm * ratio };
|
|
110
171
|
}
|
|
111
172
|
|
|
112
|
-
module.exports = {
|
|
173
|
+
module.exports = {
|
|
174
|
+
getImageDimensions,
|
|
175
|
+
resolveImageSize,
|
|
176
|
+
resolveImagePath,
|
|
177
|
+
readImageFile,
|
|
178
|
+
MAX_IMAGE_SIZE_BYTES,
|
|
179
|
+
};
|
package/core/pdf-core.js
CHANGED
|
@@ -461,12 +461,12 @@ class PDFCore {
|
|
|
461
461
|
);
|
|
462
462
|
const baseline = y + baselineOffsetMm;
|
|
463
463
|
|
|
464
|
-
if (payload.leftText) {
|
|
464
|
+
if (payload.leftText != null) {
|
|
465
465
|
this.applyTextStyle(leftStyle);
|
|
466
466
|
this.doc.text(applyTextTransform(String(payload.leftText), leftStyle.textTransform), xLeft, baseline);
|
|
467
467
|
}
|
|
468
468
|
|
|
469
|
-
if (payload.rightText) {
|
|
469
|
+
if (payload.rightText != null) {
|
|
470
470
|
this.applyTextStyle(rightStyle);
|
|
471
471
|
this.doc.text(applyTextTransform(String(payload.rightText), rightStyle.textTransform), xRight, baseline, { align: "right" });
|
|
472
472
|
}
|
|
@@ -967,7 +967,10 @@ class PDFCore {
|
|
|
967
967
|
|
|
968
968
|
_resolveColor(color, fallback) {
|
|
969
969
|
if (Array.isArray(color) && color.length === 3) {
|
|
970
|
-
return color
|
|
970
|
+
return color.map((c) => {
|
|
971
|
+
const n = Number(c) || 0;
|
|
972
|
+
return Math.max(0, Math.min(255, Math.round(n)));
|
|
973
|
+
});
|
|
971
974
|
}
|
|
972
975
|
return fallback;
|
|
973
976
|
}
|
package/core/render-document.js
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
|
-
const fs = require("fs");
|
|
2
|
-
const path = require("path");
|
|
3
1
|
const { PDFCore, getStyleMarginsMm, getTextPaddingMm, applyTextTransform } = require("./pdf-core");
|
|
4
2
|
const { pxToMm, resolveLineHeightMm } = require("./units");
|
|
5
3
|
const { registerThemeFonts } = require("./font-registry");
|
|
6
4
|
const { getPlugin, hasPlugin } = require("./plugin-registry");
|
|
7
5
|
const { validateSource, validateTheme, validateSourceAgainstTheme } = require("./validate");
|
|
8
|
-
const { getImageDimensions, resolveImageSize } = require("./image-utils");
|
|
6
|
+
const { getImageDimensions, resolveImageSize, resolveImagePath, readImageFile } = require("./image-utils");
|
|
9
7
|
|
|
10
8
|
/**
|
|
11
9
|
* Render a document by executing labeled operations.
|
|
@@ -754,31 +752,11 @@ function executeOperation(ctx) {
|
|
|
754
752
|
const bounds = getHorizontalBounds(core, templateBypassMargins);
|
|
755
753
|
const contentWidthMm = bounds.right - bounds.left;
|
|
756
754
|
|
|
757
|
-
// Read image file
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
// Containment: src must be a relative path under the working directory.
|
|
763
|
-
// Blocks absolute paths and ../ traversal so an untrusted source document
|
|
764
|
-
// cannot read arbitrary files (e.g. /etc/passwd, ../../app/.env) into the PDF.
|
|
765
|
-
if (path.isAbsolute(srcPath) || srcPath.includes("\0")) {
|
|
766
|
-
throw new Error(`Operation ${index} (image) "src" must be a relative path: "${srcPath}"`);
|
|
767
|
-
}
|
|
768
|
-
const imageBaseDir = path.resolve(process.cwd());
|
|
769
|
-
const resolvedPath = path.resolve(imageBaseDir, srcPath);
|
|
770
|
-
if (resolvedPath !== imageBaseDir && !resolvedPath.startsWith(imageBaseDir + path.sep)) {
|
|
771
|
-
throw new Error(`Operation ${index} (image) "src" escapes the working directory: "${srcPath}"`);
|
|
772
|
-
}
|
|
773
|
-
let buf;
|
|
774
|
-
try {
|
|
775
|
-
buf = fs.readFileSync(resolvedPath);
|
|
776
|
-
} catch (err) {
|
|
777
|
-
throw new Error(`Operation ${index} (image) could not read file "${resolvedPath}": ${err.message}`);
|
|
778
|
-
}
|
|
779
|
-
if (!buf || buf.length === 0) {
|
|
780
|
-
throw new Error(`Operation ${index} (image) file is empty: "${resolvedPath}"`);
|
|
781
|
-
}
|
|
755
|
+
// Read image file with containment and size limits.
|
|
756
|
+
// Containment blocks absolute paths and ../ traversal so an untrusted source
|
|
757
|
+
// document cannot read arbitrary files (e.g. /etc/passwd, ../../app/.env).
|
|
758
|
+
const resolvedPath = resolveImagePath(operation.src, index);
|
|
759
|
+
const buf = readImageFile(resolvedPath, index);
|
|
782
760
|
let imgInfo;
|
|
783
761
|
try {
|
|
784
762
|
imgInfo = getImageDimensions(buf);
|
|
@@ -1145,15 +1123,8 @@ function estimateOperationHeight(ctx) {
|
|
|
1145
1123
|
let imgHeightMm = 80; // fallback
|
|
1146
1124
|
if (operation.src) {
|
|
1147
1125
|
try {
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
}
|
|
1151
|
-
const imageBaseDir = path.resolve(process.cwd());
|
|
1152
|
-
const resolvedPath = path.resolve(imageBaseDir, operation.src);
|
|
1153
|
-
if (resolvedPath !== imageBaseDir && !resolvedPath.startsWith(imageBaseDir + path.sep)) {
|
|
1154
|
-
throw new Error(`image "src" escapes the working directory: "${operation.src}"`);
|
|
1155
|
-
}
|
|
1156
|
-
const buf = fs.readFileSync(resolvedPath);
|
|
1126
|
+
const resolvedPath = resolveImagePath(operation.src, index);
|
|
1127
|
+
const buf = readImageFile(resolvedPath, index);
|
|
1157
1128
|
const imgInfo = getImageDimensions(buf);
|
|
1158
1129
|
const resolved = resolveImageSize(operation, imgInfo.width, imgInfo.height, contentWidthMm - padding.left - padding.right);
|
|
1159
1130
|
imgHeightMm = resolved.heightMm;
|
package/core/shapes.js
CHANGED