roxify 1.1.1 → 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 +106 -62
- 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.replace('_reconst.png', '.png')}`);
|
|
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,7 +121,7 @@ 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) {
|
|
111
125
|
async function loadRaw(imgInput) {
|
|
112
126
|
const { data, info } = await sharp(imgInput)
|
|
113
127
|
.ensureAlpha()
|
|
@@ -122,11 +136,28 @@ export async function cropAndReconstitute(input) {
|
|
|
122
136
|
return a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
|
|
123
137
|
}
|
|
124
138
|
const { data, info } = await loadRaw(input);
|
|
125
|
-
const
|
|
126
|
-
|
|
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;
|
|
127
153
|
function at(x, y) {
|
|
128
154
|
const i = idxFor(x, y, w);
|
|
129
|
-
return [
|
|
155
|
+
return [
|
|
156
|
+
doubledData[i],
|
|
157
|
+
doubledData[i + 1],
|
|
158
|
+
doubledData[i + 2],
|
|
159
|
+
doubledData[i + 3],
|
|
160
|
+
];
|
|
130
161
|
}
|
|
131
162
|
let startPoint = null;
|
|
132
163
|
for (let y = 0; y < h && !startPoint; y++) {
|
|
@@ -200,7 +231,7 @@ export async function cropAndReconstitute(input) {
|
|
|
200
231
|
const cropH = sy2 - sy1 + 1;
|
|
201
232
|
if (cropW <= 0 || cropH <= 0)
|
|
202
233
|
throw new Error('Invalid crop dimensions');
|
|
203
|
-
const cropped = await sharp(
|
|
234
|
+
const cropped = await sharp(doubledBuffer)
|
|
204
235
|
.extract({ left: sx1, top: sy1, width: cropW, height: cropH })
|
|
205
236
|
.png()
|
|
206
237
|
.toBuffer();
|
|
@@ -240,25 +271,13 @@ export async function cropAndReconstitute(input) {
|
|
|
240
271
|
out[dstI + 3] = cdata[srcI + 3];
|
|
241
272
|
}
|
|
242
273
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
out[i + 3] = 255;
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
else {
|
|
254
|
-
const targetY = ch - 1;
|
|
255
|
-
for (let x = 0; x < cw; x++) {
|
|
256
|
-
const i = ((targetY * newWidth + x) * 4) | 0;
|
|
257
|
-
out[i] = 0;
|
|
258
|
-
out[i + 1] = 0;
|
|
259
|
-
out[i + 2] = 0;
|
|
260
|
-
out[i + 3] = 255;
|
|
261
|
-
}
|
|
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;
|
|
262
281
|
}
|
|
263
282
|
const lastY = ch;
|
|
264
283
|
for (let x = 0; x < newWidth; x++) {
|
|
@@ -341,11 +360,32 @@ export async function cropAndReconstitute(input) {
|
|
|
341
360
|
finalOut[i + 3] = line[x][3] === 0 ? 255 : line[x][3];
|
|
342
361
|
}
|
|
343
362
|
}
|
|
344
|
-
|
|
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;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
const resultBuffer = await sharp(finalOut, {
|
|
345
381
|
raw: { width: finalWidth, height: finalHeight, channels: 4 },
|
|
346
382
|
})
|
|
347
383
|
.png()
|
|
348
384
|
.toBuffer();
|
|
385
|
+
if (debugDir) {
|
|
386
|
+
await sharp(resultBuffer).toFile(join(debugDir, 'reconstructed.png'));
|
|
387
|
+
}
|
|
388
|
+
return resultBuffer;
|
|
349
389
|
}
|
|
350
390
|
/**
|
|
351
391
|
* Encode a Buffer into a PNG wrapper. Supports optional compression and
|
|
@@ -359,14 +399,8 @@ export async function encodeBinaryToPng(input, opts = {}) {
|
|
|
359
399
|
let payload = Buffer.concat([MAGIC, input]);
|
|
360
400
|
const brQuality = typeof opts.brQuality === 'number' ? opts.brQuality : 11;
|
|
361
401
|
const mode = opts.mode === undefined ? 'screenshot' : opts.mode;
|
|
362
|
-
const
|
|
363
|
-
|
|
364
|
-
(mode === 'compact' || mode === 'pixel' || mode === 'screenshot'));
|
|
365
|
-
if (useBrotli) {
|
|
366
|
-
payload = zlib.brotliCompressSync(payload, {
|
|
367
|
-
params: { [zlib.constants.BROTLI_PARAM_QUALITY]: brQuality },
|
|
368
|
-
});
|
|
369
|
-
}
|
|
402
|
+
const compression = opts.compression || 'zstd';
|
|
403
|
+
payload = Buffer.from(await zstdCompress(payload, 22));
|
|
370
404
|
if (opts.passphrase && !opts.encrypt) {
|
|
371
405
|
opts.encrypt = 'aes';
|
|
372
406
|
}
|
|
@@ -443,9 +477,14 @@ export async function encodeBinaryToPng(input, opts = {}) {
|
|
|
443
477
|
? Buffer.concat([dataWithoutMarkers, Buffer.alloc(padding)])
|
|
444
478
|
: dataWithoutMarkers;
|
|
445
479
|
const markerStartBytes = colorsToBytes(MARKER_START);
|
|
446
|
-
const
|
|
480
|
+
const compressionMarkerBytes = colorsToBytes(COMPRESSION_MARKERS.zstd);
|
|
481
|
+
const dataWithMarkers = Buffer.concat([
|
|
482
|
+
markerStartBytes,
|
|
483
|
+
compressionMarkerBytes,
|
|
484
|
+
paddedData,
|
|
485
|
+
]);
|
|
447
486
|
const bytesPerPixel = 3;
|
|
448
|
-
const dataPixels = Math.ceil(
|
|
487
|
+
const dataPixels = Math.ceil(dataWithMarkers.length / 3);
|
|
449
488
|
let logicalWidth = Math.ceil(Math.sqrt(dataPixels));
|
|
450
489
|
if (logicalWidth < MARKER_END.length) {
|
|
451
490
|
logicalWidth = MARKER_END.length;
|
|
@@ -455,7 +494,7 @@ export async function encodeBinaryToPng(input, opts = {}) {
|
|
|
455
494
|
const spaceInLastRow = pixelsInLastRow === 0 ? logicalWidth : logicalWidth - pixelsInLastRow;
|
|
456
495
|
const needsExtraRow = spaceInLastRow < MARKER_END.length;
|
|
457
496
|
const logicalHeight = needsExtraRow ? dataRows + 1 : dataRows;
|
|
458
|
-
const scale =
|
|
497
|
+
const scale = 1;
|
|
459
498
|
const width = logicalWidth * scale;
|
|
460
499
|
const height = logicalHeight * scale;
|
|
461
500
|
const raw = Buffer.alloc(width * height * bytesPerPixel);
|
|
@@ -473,17 +512,14 @@ export async function encodeBinaryToPng(input, opts = {}) {
|
|
|
473
512
|
else if (ly < dataRows ||
|
|
474
513
|
(ly === dataRows && linearIdx < dataPixels)) {
|
|
475
514
|
const srcIdx = linearIdx * 3;
|
|
476
|
-
r =
|
|
477
|
-
srcIdx < dataWithMarkerStart.length
|
|
478
|
-
? dataWithMarkerStart[srcIdx]
|
|
479
|
-
: 0;
|
|
515
|
+
r = srcIdx < dataWithMarkers.length ? dataWithMarkers[srcIdx] : 0;
|
|
480
516
|
g =
|
|
481
|
-
srcIdx + 1 <
|
|
482
|
-
?
|
|
517
|
+
srcIdx + 1 < dataWithMarkers.length
|
|
518
|
+
? dataWithMarkers[srcIdx + 1]
|
|
483
519
|
: 0;
|
|
484
520
|
b =
|
|
485
|
-
srcIdx + 2 <
|
|
486
|
-
?
|
|
521
|
+
srcIdx + 2 < dataWithMarkers.length
|
|
522
|
+
? dataWithMarkers[srcIdx + 2]
|
|
487
523
|
: 0;
|
|
488
524
|
}
|
|
489
525
|
for (let sy = 0; sy < scale; sy++) {
|
|
@@ -628,13 +664,13 @@ export async function decodePngToBinary(pngBuf, opts = {}) {
|
|
|
628
664
|
const rawPayload = d.slice(idx);
|
|
629
665
|
let payload = tryDecryptIfNeeded(rawPayload, opts.passphrase);
|
|
630
666
|
try {
|
|
631
|
-
payload =
|
|
667
|
+
payload = await tryZstdDecompress(payload);
|
|
632
668
|
}
|
|
633
669
|
catch (e) {
|
|
634
670
|
const errMsg = e instanceof Error ? e.message : String(e);
|
|
635
671
|
if (opts.passphrase)
|
|
636
|
-
throw new Error('Incorrect passphrase (ROX format,
|
|
637
|
-
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);
|
|
638
674
|
}
|
|
639
675
|
if (!payload.slice(0, MAGIC.length).equals(MAGIC)) {
|
|
640
676
|
throw new Error('Invalid ROX format (ROX direct: missing ROX1 magic after decompression)');
|
|
@@ -682,13 +718,13 @@ export async function decodePngToBinary(pngBuf, opts = {}) {
|
|
|
682
718
|
throw new DataFormatError('Compact mode payload empty');
|
|
683
719
|
let payload = tryDecryptIfNeeded(rawPayload, opts.passphrase);
|
|
684
720
|
try {
|
|
685
|
-
payload =
|
|
721
|
+
payload = await tryZstdDecompress(payload);
|
|
686
722
|
}
|
|
687
723
|
catch (e) {
|
|
688
724
|
const errMsg = e instanceof Error ? e.message : String(e);
|
|
689
725
|
if (opts.passphrase)
|
|
690
|
-
throw new IncorrectPassphraseError('Incorrect passphrase (compact mode,
|
|
691
|
-
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);
|
|
692
728
|
}
|
|
693
729
|
if (!payload.slice(0, MAGIC.length).equals(MAGIC)) {
|
|
694
730
|
throw new DataFormatError('Invalid ROX format (compact mode: missing ROX1 magic after decompression)');
|
|
@@ -748,7 +784,7 @@ export async function decodePngToBinary(pngBuf, opts = {}) {
|
|
|
748
784
|
logicalData = rawRGB;
|
|
749
785
|
}
|
|
750
786
|
else {
|
|
751
|
-
const reconstructed = await cropAndReconstitute(data);
|
|
787
|
+
const reconstructed = await cropAndReconstitute(data, opts.debugDir);
|
|
752
788
|
const { data: rdata, info: rinfo } = await sharp(reconstructed)
|
|
753
789
|
.ensureAlpha()
|
|
754
790
|
.raw()
|
|
@@ -794,14 +830,9 @@ export async function decodePngToBinary(pngBuf, opts = {}) {
|
|
|
794
830
|
const rawPayload = logicalData.slice(idx, idx + payloadLen);
|
|
795
831
|
let payload = tryDecryptIfNeeded(rawPayload, opts.passphrase);
|
|
796
832
|
try {
|
|
797
|
-
payload =
|
|
798
|
-
}
|
|
799
|
-
catch (e) {
|
|
800
|
-
const errMsg = e instanceof Error ? e.message : String(e);
|
|
801
|
-
if (opts.passphrase)
|
|
802
|
-
throw new IncorrectPassphraseError('Incorrect passphrase (pixel mode, brotli failed: ' + errMsg + ')');
|
|
803
|
-
throw new DataFormatError('Pixel mode brotli decompression failed: ' + errMsg);
|
|
833
|
+
payload = await tryZstdDecompress(payload);
|
|
804
834
|
}
|
|
835
|
+
catch (e) { }
|
|
805
836
|
if (!payload.slice(0, MAGIC.length).equals(MAGIC)) {
|
|
806
837
|
throw new DataFormatError('Invalid ROX format (pixel mode: missing ROX1 magic after decompression)');
|
|
807
838
|
}
|
|
@@ -931,6 +962,19 @@ export async function decodePngToBinary(pngBuf, opts = {}) {
|
|
|
931
962
|
throw new Error('Marker START not found - image format not supported');
|
|
932
963
|
}
|
|
933
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
|
+
}
|
|
934
978
|
let endStartIdx = -1;
|
|
935
979
|
const lastLineStart = (logicalHeight - 1) * logicalWidth;
|
|
936
980
|
const endMarkerStartCol = logicalWidth - MARKER_END.length;
|
|
@@ -966,7 +1010,7 @@ export async function decodePngToBinary(pngBuf, opts = {}) {
|
|
|
966
1010
|
}
|
|
967
1011
|
endStartIdx = gridFromStart.length;
|
|
968
1012
|
}
|
|
969
|
-
const dataGrid = gridFromStart.slice(MARKER_START.length, endStartIdx);
|
|
1013
|
+
const dataGrid = gridFromStart.slice(MARKER_START.length + 1, endStartIdx);
|
|
970
1014
|
const pixelBytes = Buffer.alloc(dataGrid.length * 3);
|
|
971
1015
|
for (let i = 0; i < dataGrid.length; i++) {
|
|
972
1016
|
pixelBytes[i * 3] = dataGrid[i].r;
|
|
@@ -1015,15 +1059,15 @@ export async function decodePngToBinary(pngBuf, opts = {}) {
|
|
|
1015
1059
|
const rawPayload = pixelBytes.slice(idx, idx + payloadLen);
|
|
1016
1060
|
let payload = tryDecryptIfNeeded(rawPayload, opts.passphrase);
|
|
1017
1061
|
try {
|
|
1018
|
-
payload =
|
|
1062
|
+
payload = await tryZstdDecompress(payload);
|
|
1019
1063
|
}
|
|
1020
1064
|
catch (e) {
|
|
1021
1065
|
const errMsg = e instanceof Error ? e.message : String(e);
|
|
1022
1066
|
if (opts.passphrase)
|
|
1023
|
-
throw new IncorrectPassphraseError(
|
|
1067
|
+
throw new IncorrectPassphraseError(`Incorrect passphrase (screenshot mode, zstd failed: ` +
|
|
1024
1068
|
errMsg +
|
|
1025
1069
|
')');
|
|
1026
|
-
throw new DataFormatError(
|
|
1070
|
+
throw new DataFormatError(`Screenshot mode zstd decompression failed: ` + errMsg);
|
|
1027
1071
|
}
|
|
1028
1072
|
if (!payload.slice(0, MAGIC.length).equals(MAGIC)) {
|
|
1029
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"
|