roxify 1.1.0 → 1.1.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 +12 -4
- package/dist/cli.js +25 -19
- package/dist/index.d.ts +19 -8
- package/dist/index.js +184 -127
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -6,7 +6,15 @@ Encode binary data into PNG images and decode them back. Supports CLI and progra
|
|
|
6
6
|
|
|
7
7
|
Roxify is a compact, color-based alternative to QR codes, designed specifically for digital-only use (not for printing). It encodes data using color channels (rather than monochrome patterns) for higher density, and is optimized for decoding from approximate screenshots — including nearest-neighbour resize/stretch and solid or gradient backgrounds. It is not intended for printed media and is not resilient to lossy compression or heavy image filtering.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Roxify creates PNGs that are often more space-efficient than ZIP or 7z archives for similar payloads without loss. Roxify provides superior compression ratios, making it ideal for embedding images, GIFs, audio, video, code, and other files without any quality loss — the original file is perfectly recovered upon decoding.
|
|
10
|
+
|
|
11
|
+
Key benefits:
|
|
12
|
+
|
|
13
|
+
- **Superior Compression**: Roxify outperforms traditional ZIP and 7z (LZMA) in speed and ratio, enabling smaller PNG outputs.
|
|
14
|
+
- **Lossless Embedding**: Compress and embed any file type (images, videos, code) with full fidelity restoration.
|
|
15
|
+
- **Code Efficiency**: Hyper-efficient for compressing source code, reducing file sizes dramatically.
|
|
16
|
+
- **Obfuscation & Security**: Obfuscate code or lock files with AES-256-GCM encryption, more compact than password-protected ZIPs.
|
|
17
|
+
- **Visual Data Indicator**: PNG size visually represents embedded data size, providing an intuitive overview.
|
|
10
18
|
|
|
11
19
|
## Installation
|
|
12
20
|
|
|
@@ -36,7 +44,7 @@ If no output name is provided:
|
|
|
36
44
|
|
|
37
45
|
- `-p, --passphrase <pass>` — Encrypt with AES-256-GCM
|
|
38
46
|
- `-m, --mode <mode>` — Encoding mode: `screenshot` (default), `pixel`, `compact`, `chunk`
|
|
39
|
-
- `-q, --quality <0-
|
|
47
|
+
- `-q, --quality <0-22>` — Roxify compression level (default: 22)
|
|
40
48
|
- `--no-compress` — Disable compression
|
|
41
49
|
- `-v, --verbose` — Show detailed errors
|
|
42
50
|
|
|
@@ -68,8 +76,8 @@ console.log(meta?.name);
|
|
|
68
76
|
- `mode` — `'screenshot'` | `'pixel'` | `'compact'` | `'chunk'` (default: `'screenshot'`)
|
|
69
77
|
- `name` — Original filename (embedded as metadata)
|
|
70
78
|
- `passphrase` — Encryption passphrase (uses AES-256-GCM)
|
|
71
|
-
- `compression` — `'
|
|
72
|
-
- `brQuality` —
|
|
79
|
+
- `compression` — `'Roxify'` | `'none'` (default: `'Roxify'`)
|
|
80
|
+
- `brQuality` — Roxify compression level 0-22 (default: 22)
|
|
73
81
|
|
|
74
82
|
## Example: Express Endpoint
|
|
75
83
|
|
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync, writeFileSync } from 'fs';
|
|
3
|
-
import { basename, resolve } from 'path';
|
|
3
|
+
import { basename, dirname, join, resolve } from 'path';
|
|
4
|
+
import sharp from 'sharp';
|
|
4
5
|
import { cropAndReconstitute, DataFormatError, decodePngToBinary, encodeBinaryToPng, IncorrectPassphraseError, PassphraseRequiredError, } from './index.js';
|
|
5
6
|
const VERSION = '1.0.4';
|
|
6
7
|
function showHelp() {
|
|
@@ -22,6 +23,7 @@ Options:
|
|
|
22
23
|
--no-compress Disable compression
|
|
23
24
|
-o, --output <path> Output file path
|
|
24
25
|
--view-reconst Export the reconstituted PNG for debugging
|
|
26
|
+
--debug Export debug images (doubled.png, reconstructed.png)
|
|
25
27
|
-v, --verbose Show detailed errors
|
|
26
28
|
|
|
27
29
|
Run "npx rox help" for this message.
|
|
@@ -46,6 +48,10 @@ function parseArgs(args) {
|
|
|
46
48
|
parsed.viewReconst = true;
|
|
47
49
|
i++;
|
|
48
50
|
}
|
|
51
|
+
else if (key === 'debug') {
|
|
52
|
+
parsed.debug = true;
|
|
53
|
+
i++;
|
|
54
|
+
}
|
|
49
55
|
else if (key === 'debug-dir') {
|
|
50
56
|
parsed.debugDir = args[i + 1];
|
|
51
57
|
i += 2;
|
|
@@ -159,33 +165,33 @@ async function decodeCommand(args) {
|
|
|
159
165
|
}
|
|
160
166
|
const resolvedInput = resolve(inputPath);
|
|
161
167
|
try {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const reconst = await cropAndReconstitute(inputBuffer);
|
|
166
|
-
inputBuffer = reconst;
|
|
167
|
-
resolvedInputPath = resolvedInput.replace(/(\.\w+)?$/, '_reconst.png');
|
|
168
|
-
if (parsed.viewReconst) {
|
|
169
|
-
writeFileSync(resolvedInputPath, reconst);
|
|
170
|
-
console.log(`Reconst PNG: ${resolvedInputPath}`);
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
catch (e) {
|
|
174
|
-
console.log('Could not generate reconst PNG:', e.message);
|
|
175
|
-
}
|
|
176
|
-
console.log(`Reading: ${resolvedInputPath}`);
|
|
168
|
+
const inputBuffer = readFileSync(resolvedInput);
|
|
169
|
+
console.log(`Reading: ${resolvedInput}`);
|
|
170
|
+
const info = await sharp(inputBuffer).metadata();
|
|
177
171
|
const options = {};
|
|
178
172
|
if (parsed.passphrase) {
|
|
179
173
|
options.passphrase = parsed.passphrase;
|
|
180
174
|
}
|
|
181
|
-
if (parsed.
|
|
182
|
-
options.debugDir =
|
|
175
|
+
if (parsed.debug) {
|
|
176
|
+
options.debugDir = dirname(resolvedInput);
|
|
183
177
|
}
|
|
184
178
|
console.log(`Decoding...`);
|
|
185
179
|
const startDecode = Date.now();
|
|
186
180
|
if (parsed.verbose)
|
|
187
181
|
options.verbose = true;
|
|
188
|
-
const
|
|
182
|
+
const doubledBuffer = await sharp(inputBuffer)
|
|
183
|
+
.resize({
|
|
184
|
+
width: info.width * 2,
|
|
185
|
+
height: info.height * 2,
|
|
186
|
+
kernel: 'nearest',
|
|
187
|
+
})
|
|
188
|
+
.png()
|
|
189
|
+
.toBuffer();
|
|
190
|
+
if (options.debugDir) {
|
|
191
|
+
writeFileSync(join(options.debugDir, 'doubled.png'), doubledBuffer);
|
|
192
|
+
}
|
|
193
|
+
const reconstructedBuffer = await cropAndReconstitute(doubledBuffer, options.debugDir);
|
|
194
|
+
const result = await decodePngToBinary(reconstructedBuffer, options);
|
|
189
195
|
const decodeTime = Date.now() - startDecode;
|
|
190
196
|
const resolvedOutput = parsed.output || outputPath || result.meta?.name || 'decoded.bin';
|
|
191
197
|
writeFileSync(resolvedOutput, result.buf);
|
package/dist/index.d.ts
CHANGED
|
@@ -16,11 +16,10 @@ export declare class DataFormatError extends Error {
|
|
|
16
16
|
export interface EncodeOptions {
|
|
17
17
|
/**
|
|
18
18
|
* Compression algorithm to use.
|
|
19
|
-
* - `'
|
|
20
|
-
*
|
|
21
|
-
* @defaultValue `'br'` for most modes
|
|
19
|
+
* - `'zstd'`: Zstandard compression (maximum compression for smallest files)
|
|
20
|
+
* @defaultValue `'zstd'`
|
|
22
21
|
*/
|
|
23
|
-
compression?: '
|
|
22
|
+
compression?: 'zstd';
|
|
24
23
|
/**
|
|
25
24
|
* Passphrase for encryption. If provided without `encrypt` option, defaults to AES-256-GCM.
|
|
26
25
|
*/
|
|
@@ -91,7 +90,21 @@ export interface DecodeResult {
|
|
|
91
90
|
name?: string;
|
|
92
91
|
};
|
|
93
92
|
}
|
|
94
|
-
|
|
93
|
+
/**
|
|
94
|
+
* Options for decoding a PNG back to binary data.
|
|
95
|
+
* @public
|
|
96
|
+
*/
|
|
97
|
+
export interface DecodeOptions {
|
|
98
|
+
/**
|
|
99
|
+
* Passphrase for encrypted inputs.
|
|
100
|
+
*/
|
|
101
|
+
passphrase?: string;
|
|
102
|
+
/**
|
|
103
|
+
* Directory to save debug images (doubled.png, reconstructed.png).
|
|
104
|
+
*/
|
|
105
|
+
debugDir?: string;
|
|
106
|
+
}
|
|
107
|
+
export declare function cropAndReconstitute(input: Buffer, debugDir?: string): Promise<Buffer>;
|
|
95
108
|
/**
|
|
96
109
|
* Encode a Buffer into a PNG wrapper. Supports optional compression and
|
|
97
110
|
* encryption. Defaults are chosen for a good balance between speed and size.
|
|
@@ -109,6 +122,4 @@ export declare function encodeBinaryToPng(input: Buffer, opts?: EncodeOptions):
|
|
|
109
122
|
* @param opts - Options (passphrase for encrypted inputs)
|
|
110
123
|
* @public
|
|
111
124
|
*/
|
|
112
|
-
export declare function decodePngToBinary(pngBuf: Buffer, opts?:
|
|
113
|
-
passphrase?: string;
|
|
114
|
-
}): Promise<DecodeResult>;
|
|
125
|
+
export declare function decodePngToBinary(pngBuf: Buffer, opts?: DecodeOptions): Promise<DecodeResult>;
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { compress as zstdCompress, decompress as zstdDecompress, } from '@mongodb-js/zstd';
|
|
1
2
|
import { createCipheriv, createDecipheriv, pbkdf2Sync, randomBytes, } from 'crypto';
|
|
3
|
+
import { join } from 'path';
|
|
2
4
|
import encode from 'png-chunks-encode';
|
|
3
5
|
import extract from 'png-chunks-extract';
|
|
4
6
|
import sharp from 'sharp';
|
|
@@ -38,6 +40,9 @@ const MARKER_COLORS = [
|
|
|
38
40
|
];
|
|
39
41
|
const MARKER_START = MARKER_COLORS;
|
|
40
42
|
const MARKER_END = [...MARKER_COLORS].reverse();
|
|
43
|
+
const COMPRESSION_MARKERS = {
|
|
44
|
+
zstd: [{ r: 0, g: 255, b: 0 }],
|
|
45
|
+
};
|
|
41
46
|
function colorsToBytes(colors) {
|
|
42
47
|
const buf = Buffer.alloc(colors.length * 3);
|
|
43
48
|
for (let i = 0; i < colors.length; i++) {
|
|
@@ -58,6 +63,15 @@ function applyXor(buf, passphrase) {
|
|
|
58
63
|
function tryBrotliDecompress(payload) {
|
|
59
64
|
return Buffer.from(zlib.brotliDecompressSync(payload));
|
|
60
65
|
}
|
|
66
|
+
async function tryZstdDecompress(payload) {
|
|
67
|
+
try {
|
|
68
|
+
const result = await zstdDecompress(payload);
|
|
69
|
+
return Buffer.from(result);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return payload;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
61
75
|
function tryDecryptIfNeeded(buf, passphrase) {
|
|
62
76
|
if (!buf || buf.length === 0)
|
|
63
77
|
return buf;
|
|
@@ -107,13 +121,43 @@ async function loadRaw(imgInput) {
|
|
|
107
121
|
.toBuffer({ resolveWithObject: true });
|
|
108
122
|
return { data, info };
|
|
109
123
|
}
|
|
110
|
-
export async function cropAndReconstitute(input) {
|
|
124
|
+
export async function cropAndReconstitute(input, debugDir) {
|
|
125
|
+
async function loadRaw(imgInput) {
|
|
126
|
+
const { data, info } = await sharp(imgInput)
|
|
127
|
+
.ensureAlpha()
|
|
128
|
+
.raw()
|
|
129
|
+
.toBuffer({ resolveWithObject: true });
|
|
130
|
+
return { data, info };
|
|
131
|
+
}
|
|
132
|
+
function idxFor(x, y, width) {
|
|
133
|
+
return (y * width + x) * 4;
|
|
134
|
+
}
|
|
135
|
+
function eqRGB(a, b) {
|
|
136
|
+
return a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
|
|
137
|
+
}
|
|
111
138
|
const { data, info } = await loadRaw(input);
|
|
112
|
-
const
|
|
113
|
-
|
|
139
|
+
const doubledBuffer = await sharp(input)
|
|
140
|
+
.resize({
|
|
141
|
+
width: info.width * 2,
|
|
142
|
+
height: info.height * 2,
|
|
143
|
+
kernel: 'nearest',
|
|
144
|
+
})
|
|
145
|
+
.png()
|
|
146
|
+
.toBuffer();
|
|
147
|
+
if (debugDir) {
|
|
148
|
+
await sharp(doubledBuffer).toFile(join(debugDir, 'doubled.png'));
|
|
149
|
+
}
|
|
150
|
+
const { data: doubledData, info: doubledInfo } = await loadRaw(doubledBuffer);
|
|
151
|
+
const w = doubledInfo.width;
|
|
152
|
+
const h = doubledInfo.height;
|
|
114
153
|
function at(x, y) {
|
|
115
154
|
const i = idxFor(x, y, w);
|
|
116
|
-
return [
|
|
155
|
+
return [
|
|
156
|
+
doubledData[i],
|
|
157
|
+
doubledData[i + 1],
|
|
158
|
+
doubledData[i + 2],
|
|
159
|
+
doubledData[i + 3],
|
|
160
|
+
];
|
|
117
161
|
}
|
|
118
162
|
let startPoint = null;
|
|
119
163
|
for (let y = 0; y < h && !startPoint; y++) {
|
|
@@ -183,11 +227,11 @@ export async function cropAndReconstitute(input) {
|
|
|
183
227
|
const sy1 = Math.min(startPoint.y, endPoint.y);
|
|
184
228
|
const sx2 = Math.max(startPoint.x, endPoint.x);
|
|
185
229
|
const sy2 = Math.max(startPoint.y, endPoint.y);
|
|
186
|
-
const cropW = sx2 - sx1;
|
|
187
|
-
const cropH = sy2 - sy1;
|
|
230
|
+
const cropW = sx2 - sx1 + 1;
|
|
231
|
+
const cropH = sy2 - sy1 + 1;
|
|
188
232
|
if (cropW <= 0 || cropH <= 0)
|
|
189
233
|
throw new Error('Invalid crop dimensions');
|
|
190
|
-
const cropped = await sharp(
|
|
234
|
+
const cropped = await sharp(doubledBuffer)
|
|
191
235
|
.extract({ left: sx1, top: sy1, width: cropW, height: cropH })
|
|
192
236
|
.png()
|
|
193
237
|
.toBuffer();
|
|
@@ -212,19 +256,72 @@ export async function cropAndReconstitute(input) {
|
|
|
212
256
|
return false;
|
|
213
257
|
return true;
|
|
214
258
|
}
|
|
215
|
-
const
|
|
259
|
+
const newWidth = cw;
|
|
260
|
+
const newHeight = ch + 1;
|
|
261
|
+
const out = Buffer.alloc(newWidth * newHeight * 4, 0);
|
|
262
|
+
for (let i = 0; i < out.length; i += 4)
|
|
263
|
+
out[i + 3] = 255;
|
|
216
264
|
for (let y = 0; y < ch; y++) {
|
|
265
|
+
for (let x = 0; x < cw; x++) {
|
|
266
|
+
const srcI = ((y * cw + x) * 4) | 0;
|
|
267
|
+
const dstI = ((y * newWidth + x) * 4) | 0;
|
|
268
|
+
out[dstI] = cdata[srcI];
|
|
269
|
+
out[dstI + 1] = cdata[srcI + 1];
|
|
270
|
+
out[dstI + 2] = cdata[srcI + 2];
|
|
271
|
+
out[dstI + 3] = cdata[srcI + 3];
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const targetY = ch - 1;
|
|
275
|
+
for (let x = 0; x < cw; x++) {
|
|
276
|
+
const i = ((targetY * newWidth + x) * 4) | 0;
|
|
277
|
+
out[i] = 0;
|
|
278
|
+
out[i + 1] = 0;
|
|
279
|
+
out[i + 2] = 0;
|
|
280
|
+
out[i + 3] = 255;
|
|
281
|
+
}
|
|
282
|
+
const lastY = ch;
|
|
283
|
+
for (let x = 0; x < newWidth; x++) {
|
|
284
|
+
const i = ((lastY * newWidth + x) * 4) | 0;
|
|
285
|
+
out[i] = 0;
|
|
286
|
+
out[i + 1] = 0;
|
|
287
|
+
out[i + 2] = 0;
|
|
288
|
+
out[i + 3] = 255;
|
|
289
|
+
}
|
|
290
|
+
if (newWidth >= 3) {
|
|
291
|
+
const bgrStart = newWidth - 3;
|
|
292
|
+
let i = ((lastY * newWidth + bgrStart) * 4) | 0;
|
|
293
|
+
out[i] = 0;
|
|
294
|
+
out[i + 1] = 0;
|
|
295
|
+
out[i + 2] = 255;
|
|
296
|
+
out[i + 3] = 255;
|
|
297
|
+
i = ((lastY * newWidth + bgrStart + 1) * 4) | 0;
|
|
298
|
+
out[i] = 0;
|
|
299
|
+
out[i + 1] = 255;
|
|
300
|
+
out[i + 2] = 0;
|
|
301
|
+
out[i + 3] = 255;
|
|
302
|
+
i = ((lastY * newWidth + bgrStart + 2) * 4) | 0;
|
|
303
|
+
out[i] = 255;
|
|
304
|
+
out[i + 1] = 0;
|
|
305
|
+
out[i + 2] = 0;
|
|
306
|
+
out[i + 3] = 255;
|
|
307
|
+
}
|
|
308
|
+
function getPixel(x, y) {
|
|
309
|
+
const i = ((y * newWidth + x) * 4) | 0;
|
|
310
|
+
return [out[i], out[i + 1], out[i + 2], out[i + 3]];
|
|
311
|
+
}
|
|
312
|
+
const compressedLines = [];
|
|
313
|
+
for (let y = 0; y < newHeight; y++) {
|
|
217
314
|
const line = [];
|
|
218
315
|
let x = 0;
|
|
219
|
-
while (x <
|
|
220
|
-
const current =
|
|
316
|
+
while (x < newWidth) {
|
|
317
|
+
const current = getPixel(x, y);
|
|
221
318
|
if (current[0] === 0 && current[1] === 0 && current[2] === 0) {
|
|
222
319
|
x++;
|
|
223
320
|
continue;
|
|
224
321
|
}
|
|
225
322
|
line.push(current);
|
|
226
323
|
let nx = x + 1;
|
|
227
|
-
while (nx <
|
|
324
|
+
while (nx < newWidth && eq(getPixel(nx, y), current))
|
|
228
325
|
nx++;
|
|
229
326
|
x = nx;
|
|
230
327
|
}
|
|
@@ -246,93 +343,49 @@ export async function cropAndReconstitute(input) {
|
|
|
246
343
|
.png()
|
|
247
344
|
.toBuffer();
|
|
248
345
|
}
|
|
249
|
-
const
|
|
250
|
-
const
|
|
251
|
-
const
|
|
252
|
-
for (let i = 0; i <
|
|
253
|
-
|
|
346
|
+
const finalWidth = Math.max(...compressedLines.map((l) => l.length));
|
|
347
|
+
const finalHeight = compressedLines.length;
|
|
348
|
+
const finalOut = Buffer.alloc(finalWidth * finalHeight * 4, 0);
|
|
349
|
+
for (let i = 0; i < finalOut.length; i += 4)
|
|
350
|
+
finalOut[i + 3] = 255;
|
|
254
351
|
for (let y = 0; y < compressedLines.length; y++) {
|
|
255
352
|
const line = compressedLines[y];
|
|
256
|
-
const
|
|
257
|
-
const startX = 0;
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
out[i + 1] = line[x][1];
|
|
265
|
-
out[i + 2] = line[x][2];
|
|
266
|
-
out[i + 3] = line[x][3] === 0 ? 255 : line[x][3];
|
|
267
|
-
}
|
|
268
|
-
if (isSecondToLast) {
|
|
269
|
-
for (let x = effectiveLength; x < Math.min(effectiveLength + 3, newWidth); x++) {
|
|
270
|
-
const i = ((y * newWidth + x) * 4) | 0;
|
|
271
|
-
out[i] = 0;
|
|
272
|
-
out[i + 1] = 0;
|
|
273
|
-
out[i + 2] = 0;
|
|
274
|
-
out[i + 3] = 255;
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
const secondToLastY = newHeight - 2;
|
|
279
|
-
let secondToLastIsBlack = true;
|
|
280
|
-
for (let x = 0; x < newWidth; x++) {
|
|
281
|
-
const i = ((secondToLastY * newWidth + x) * 4) | 0;
|
|
282
|
-
if (out[i] !== 0 || out[i + 1] !== 0 || out[i + 2] !== 0) {
|
|
283
|
-
secondToLastIsBlack = false;
|
|
284
|
-
break;
|
|
353
|
+
const isLastLine = y === compressedLines.length - 1;
|
|
354
|
+
const startX = isLastLine ? finalWidth - line.length : 0;
|
|
355
|
+
for (let x = 0; x < line.length; x++) {
|
|
356
|
+
const i = ((y * finalWidth + startX + x) * 4) | 0;
|
|
357
|
+
finalOut[i] = line[x][0];
|
|
358
|
+
finalOut[i + 1] = line[x][1];
|
|
359
|
+
finalOut[i + 2] = line[x][2];
|
|
360
|
+
finalOut[i + 3] = line[x][3] === 0 ? 255 : line[x][3];
|
|
285
361
|
}
|
|
286
362
|
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
finalOut[i +
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
finalOut[
|
|
299
|
-
finalOut[
|
|
300
|
-
finalOut[
|
|
301
|
-
finalOut[dstI + 3] = out[srcI + 3];
|
|
363
|
+
if (finalHeight >= 2) {
|
|
364
|
+
const secondLastY = finalHeight - 2;
|
|
365
|
+
for (let x = 0; x < finalWidth; x++) {
|
|
366
|
+
const i = ((secondLastY * finalWidth + x) * 4) | 0;
|
|
367
|
+
const r = finalOut[i];
|
|
368
|
+
const g = finalOut[i + 1];
|
|
369
|
+
const b = finalOut[i + 2];
|
|
370
|
+
if ((r === 255 && g === 0 && b === 0) ||
|
|
371
|
+
(r === 0 && g === 255 && b === 0) ||
|
|
372
|
+
(r === 0 && g === 0 && b === 255)) {
|
|
373
|
+
finalOut[i] = 0;
|
|
374
|
+
finalOut[i + 1] = 0;
|
|
375
|
+
finalOut[i + 2] = 0;
|
|
376
|
+
finalOut[i + 3] = 255;
|
|
302
377
|
}
|
|
303
378
|
}
|
|
304
379
|
}
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
const i = ((lastY * newWidth + x) * 4) | 0;
|
|
308
|
-
finalOut[i] = 0;
|
|
309
|
-
finalOut[i + 1] = 0;
|
|
310
|
-
finalOut[i + 2] = 0;
|
|
311
|
-
finalOut[i + 3] = 255;
|
|
312
|
-
}
|
|
313
|
-
if (newWidth >= 3) {
|
|
314
|
-
const bgrStart = newWidth - 3;
|
|
315
|
-
let i = ((lastY * newWidth + bgrStart) * 4) | 0;
|
|
316
|
-
finalOut[i] = 0;
|
|
317
|
-
finalOut[i + 1] = 0;
|
|
318
|
-
finalOut[i + 2] = 255;
|
|
319
|
-
finalOut[i + 3] = 255;
|
|
320
|
-
i = ((lastY * newWidth + bgrStart + 1) * 4) | 0;
|
|
321
|
-
finalOut[i] = 0;
|
|
322
|
-
finalOut[i + 1] = 255;
|
|
323
|
-
finalOut[i + 2] = 0;
|
|
324
|
-
finalOut[i + 3] = 255;
|
|
325
|
-
i = ((lastY * newWidth + bgrStart + 2) * 4) | 0;
|
|
326
|
-
finalOut[i] = 255;
|
|
327
|
-
finalOut[i + 1] = 0;
|
|
328
|
-
finalOut[i + 2] = 0;
|
|
329
|
-
finalOut[i + 3] = 255;
|
|
330
|
-
}
|
|
331
|
-
return sharp(finalOut, {
|
|
332
|
-
raw: { width: newWidth, height: finalHeight, channels: 4 },
|
|
380
|
+
const resultBuffer = await sharp(finalOut, {
|
|
381
|
+
raw: { width: finalWidth, height: finalHeight, channels: 4 },
|
|
333
382
|
})
|
|
334
383
|
.png()
|
|
335
384
|
.toBuffer();
|
|
385
|
+
if (debugDir) {
|
|
386
|
+
await sharp(resultBuffer).toFile(join(debugDir, 'reconstructed.png'));
|
|
387
|
+
}
|
|
388
|
+
return resultBuffer;
|
|
336
389
|
}
|
|
337
390
|
/**
|
|
338
391
|
* Encode a Buffer into a PNG wrapper. Supports optional compression and
|
|
@@ -346,14 +399,8 @@ export async function encodeBinaryToPng(input, opts = {}) {
|
|
|
346
399
|
let payload = Buffer.concat([MAGIC, input]);
|
|
347
400
|
const brQuality = typeof opts.brQuality === 'number' ? opts.brQuality : 11;
|
|
348
401
|
const mode = opts.mode === undefined ? 'screenshot' : opts.mode;
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
(mode === 'compact' || mode === 'pixel' || mode === 'screenshot'));
|
|
352
|
-
if (useBrotli) {
|
|
353
|
-
payload = zlib.brotliCompressSync(payload, {
|
|
354
|
-
params: { [zlib.constants.BROTLI_PARAM_QUALITY]: brQuality },
|
|
355
|
-
});
|
|
356
|
-
}
|
|
402
|
+
const compression = opts.compression || 'zstd';
|
|
403
|
+
payload = Buffer.from(await zstdCompress(payload, 22));
|
|
357
404
|
if (opts.passphrase && !opts.encrypt) {
|
|
358
405
|
opts.encrypt = 'aes';
|
|
359
406
|
}
|
|
@@ -430,9 +477,14 @@ export async function encodeBinaryToPng(input, opts = {}) {
|
|
|
430
477
|
? Buffer.concat([dataWithoutMarkers, Buffer.alloc(padding)])
|
|
431
478
|
: dataWithoutMarkers;
|
|
432
479
|
const markerStartBytes = colorsToBytes(MARKER_START);
|
|
433
|
-
const
|
|
480
|
+
const compressionMarkerBytes = colorsToBytes(COMPRESSION_MARKERS.zstd);
|
|
481
|
+
const dataWithMarkers = Buffer.concat([
|
|
482
|
+
markerStartBytes,
|
|
483
|
+
compressionMarkerBytes,
|
|
484
|
+
paddedData,
|
|
485
|
+
]);
|
|
434
486
|
const bytesPerPixel = 3;
|
|
435
|
-
const dataPixels = Math.ceil(
|
|
487
|
+
const dataPixels = Math.ceil(dataWithMarkers.length / 3);
|
|
436
488
|
let logicalWidth = Math.ceil(Math.sqrt(dataPixels));
|
|
437
489
|
if (logicalWidth < MARKER_END.length) {
|
|
438
490
|
logicalWidth = MARKER_END.length;
|
|
@@ -460,17 +512,14 @@ export async function encodeBinaryToPng(input, opts = {}) {
|
|
|
460
512
|
else if (ly < dataRows ||
|
|
461
513
|
(ly === dataRows && linearIdx < dataPixels)) {
|
|
462
514
|
const srcIdx = linearIdx * 3;
|
|
463
|
-
r =
|
|
464
|
-
srcIdx < dataWithMarkerStart.length
|
|
465
|
-
? dataWithMarkerStart[srcIdx]
|
|
466
|
-
: 0;
|
|
515
|
+
r = srcIdx < dataWithMarkers.length ? dataWithMarkers[srcIdx] : 0;
|
|
467
516
|
g =
|
|
468
|
-
srcIdx + 1 <
|
|
469
|
-
?
|
|
517
|
+
srcIdx + 1 < dataWithMarkers.length
|
|
518
|
+
? dataWithMarkers[srcIdx + 1]
|
|
470
519
|
: 0;
|
|
471
520
|
b =
|
|
472
|
-
srcIdx + 2 <
|
|
473
|
-
?
|
|
521
|
+
srcIdx + 2 < dataWithMarkers.length
|
|
522
|
+
? dataWithMarkers[srcIdx + 2]
|
|
474
523
|
: 0;
|
|
475
524
|
}
|
|
476
525
|
for (let sy = 0; sy < scale; sy++) {
|
|
@@ -489,10 +538,10 @@ export async function encodeBinaryToPng(input, opts = {}) {
|
|
|
489
538
|
raw: { width, height, channels: 3 },
|
|
490
539
|
})
|
|
491
540
|
.png({
|
|
492
|
-
compressionLevel:
|
|
541
|
+
compressionLevel: 9,
|
|
493
542
|
palette: false,
|
|
494
|
-
effort:
|
|
495
|
-
adaptiveFiltering:
|
|
543
|
+
effort: 10,
|
|
544
|
+
adaptiveFiltering: true,
|
|
496
545
|
})
|
|
497
546
|
.toBuffer();
|
|
498
547
|
}
|
|
@@ -615,13 +664,13 @@ export async function decodePngToBinary(pngBuf, opts = {}) {
|
|
|
615
664
|
const rawPayload = d.slice(idx);
|
|
616
665
|
let payload = tryDecryptIfNeeded(rawPayload, opts.passphrase);
|
|
617
666
|
try {
|
|
618
|
-
payload =
|
|
667
|
+
payload = await tryZstdDecompress(payload);
|
|
619
668
|
}
|
|
620
669
|
catch (e) {
|
|
621
670
|
const errMsg = e instanceof Error ? e.message : String(e);
|
|
622
671
|
if (opts.passphrase)
|
|
623
|
-
throw new Error('Incorrect passphrase (ROX format,
|
|
624
|
-
throw new Error('ROX format
|
|
672
|
+
throw new Error('Incorrect passphrase (ROX format, zstd failed: ' + errMsg + ')');
|
|
673
|
+
throw new Error('ROX format zstd decompression failed: ' + errMsg);
|
|
625
674
|
}
|
|
626
675
|
if (!payload.slice(0, MAGIC.length).equals(MAGIC)) {
|
|
627
676
|
throw new Error('Invalid ROX format (ROX direct: missing ROX1 magic after decompression)');
|
|
@@ -669,13 +718,13 @@ export async function decodePngToBinary(pngBuf, opts = {}) {
|
|
|
669
718
|
throw new DataFormatError('Compact mode payload empty');
|
|
670
719
|
let payload = tryDecryptIfNeeded(rawPayload, opts.passphrase);
|
|
671
720
|
try {
|
|
672
|
-
payload =
|
|
721
|
+
payload = await tryZstdDecompress(payload);
|
|
673
722
|
}
|
|
674
723
|
catch (e) {
|
|
675
724
|
const errMsg = e instanceof Error ? e.message : String(e);
|
|
676
725
|
if (opts.passphrase)
|
|
677
|
-
throw new IncorrectPassphraseError('Incorrect passphrase (compact mode,
|
|
678
|
-
throw new DataFormatError('Compact mode
|
|
726
|
+
throw new IncorrectPassphraseError('Incorrect passphrase (compact mode, zstd failed: ' + errMsg + ')');
|
|
727
|
+
throw new DataFormatError('Compact mode zstd decompression failed: ' + errMsg);
|
|
679
728
|
}
|
|
680
729
|
if (!payload.slice(0, MAGIC.length).equals(MAGIC)) {
|
|
681
730
|
throw new DataFormatError('Invalid ROX format (compact mode: missing ROX1 magic after decompression)');
|
|
@@ -735,7 +784,7 @@ export async function decodePngToBinary(pngBuf, opts = {}) {
|
|
|
735
784
|
logicalData = rawRGB;
|
|
736
785
|
}
|
|
737
786
|
else {
|
|
738
|
-
const reconstructed = await cropAndReconstitute(data);
|
|
787
|
+
const reconstructed = await cropAndReconstitute(data, opts.debugDir);
|
|
739
788
|
const { data: rdata, info: rinfo } = await sharp(reconstructed)
|
|
740
789
|
.ensureAlpha()
|
|
741
790
|
.raw()
|
|
@@ -781,14 +830,9 @@ export async function decodePngToBinary(pngBuf, opts = {}) {
|
|
|
781
830
|
const rawPayload = logicalData.slice(idx, idx + payloadLen);
|
|
782
831
|
let payload = tryDecryptIfNeeded(rawPayload, opts.passphrase);
|
|
783
832
|
try {
|
|
784
|
-
payload =
|
|
785
|
-
}
|
|
786
|
-
catch (e) {
|
|
787
|
-
const errMsg = e instanceof Error ? e.message : String(e);
|
|
788
|
-
if (opts.passphrase)
|
|
789
|
-
throw new IncorrectPassphraseError('Incorrect passphrase (pixel mode, brotli failed: ' + errMsg + ')');
|
|
790
|
-
throw new DataFormatError('Pixel mode brotli decompression failed: ' + errMsg);
|
|
833
|
+
payload = await tryZstdDecompress(payload);
|
|
791
834
|
}
|
|
835
|
+
catch (e) { }
|
|
792
836
|
if (!payload.slice(0, MAGIC.length).equals(MAGIC)) {
|
|
793
837
|
throw new DataFormatError('Invalid ROX format (pixel mode: missing ROX1 magic after decompression)');
|
|
794
838
|
}
|
|
@@ -918,6 +962,19 @@ export async function decodePngToBinary(pngBuf, opts = {}) {
|
|
|
918
962
|
throw new Error('Marker START not found - image format not supported');
|
|
919
963
|
}
|
|
920
964
|
}
|
|
965
|
+
let compression = 'zstd';
|
|
966
|
+
if (gridFromStart.length > MARKER_START.length) {
|
|
967
|
+
const compPixel = gridFromStart[MARKER_START.length];
|
|
968
|
+
if (compPixel.r === 0 && compPixel.g === 255 && compPixel.b === 0) {
|
|
969
|
+
compression = 'zstd';
|
|
970
|
+
}
|
|
971
|
+
else {
|
|
972
|
+
compression = 'zstd';
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
if (process.env.ROX_DEBUG) {
|
|
976
|
+
console.log(`DEBUG: Detected compression: ${compression}`);
|
|
977
|
+
}
|
|
921
978
|
let endStartIdx = -1;
|
|
922
979
|
const lastLineStart = (logicalHeight - 1) * logicalWidth;
|
|
923
980
|
const endMarkerStartCol = logicalWidth - MARKER_END.length;
|
|
@@ -953,7 +1010,7 @@ export async function decodePngToBinary(pngBuf, opts = {}) {
|
|
|
953
1010
|
}
|
|
954
1011
|
endStartIdx = gridFromStart.length;
|
|
955
1012
|
}
|
|
956
|
-
const dataGrid = gridFromStart.slice(MARKER_START.length, endStartIdx);
|
|
1013
|
+
const dataGrid = gridFromStart.slice(MARKER_START.length + 1, endStartIdx);
|
|
957
1014
|
const pixelBytes = Buffer.alloc(dataGrid.length * 3);
|
|
958
1015
|
for (let i = 0; i < dataGrid.length; i++) {
|
|
959
1016
|
pixelBytes[i * 3] = dataGrid[i].r;
|
|
@@ -1002,15 +1059,15 @@ export async function decodePngToBinary(pngBuf, opts = {}) {
|
|
|
1002
1059
|
const rawPayload = pixelBytes.slice(idx, idx + payloadLen);
|
|
1003
1060
|
let payload = tryDecryptIfNeeded(rawPayload, opts.passphrase);
|
|
1004
1061
|
try {
|
|
1005
|
-
payload =
|
|
1062
|
+
payload = await tryZstdDecompress(payload);
|
|
1006
1063
|
}
|
|
1007
1064
|
catch (e) {
|
|
1008
1065
|
const errMsg = e instanceof Error ? e.message : String(e);
|
|
1009
1066
|
if (opts.passphrase)
|
|
1010
|
-
throw new IncorrectPassphraseError(
|
|
1067
|
+
throw new IncorrectPassphraseError(`Incorrect passphrase (screenshot mode, zstd failed: ` +
|
|
1011
1068
|
errMsg +
|
|
1012
1069
|
')');
|
|
1013
|
-
throw new DataFormatError(
|
|
1070
|
+
throw new DataFormatError(`Screenshot mode zstd decompression failed: ` + errMsg);
|
|
1014
1071
|
}
|
|
1015
1072
|
if (!payload.slice(0, MAGIC.length).equals(MAGIC)) {
|
|
1016
1073
|
throw new DataFormatError('Invalid ROX format (pixel mode: missing ROX1 magic after decompression)');
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "roxify",
|
|
3
|
-
"version": "1.1.
|
|
4
|
-
"description": "Encode binary data into PNG images and decode them back. Supports CLI and programmatic API (Node.js ESM).",
|
|
3
|
+
"version": "1.1.2",
|
|
4
|
+
"description": "Encode binary data into PNG images with Zstd compression and decode them back. Supports CLI and programmatic API (Node.js ESM).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"types": "dist/index.d.ts",
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
"node": ">=18"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
+
"@mongodb-js/zstd": "^7.0.0",
|
|
43
44
|
"png-chunks-encode": "^1.0.0",
|
|
44
45
|
"png-chunks-extract": "^1.0.0",
|
|
45
46
|
"sharp": "^0.34.5"
|