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 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
- Roxified PNGs are often more space-efficient than ZIP archives for similar payloads and provide a visual indication of the embedded data size.
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-11>` — Brotli compression quality (default: 11)
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` — `'br'` | `'none'` (default: `'br'`)
72
- - `brQuality` — Brotli quality 0-11 (default: 4)
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
- let inputBuffer = readFileSync(resolvedInput);
163
- let resolvedInputPath = resolvedInput;
164
- try {
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.debugDir) {
182
- options.debugDir = parsed.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 result = await decodePngToBinary(inputBuffer, options);
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
- * - `'br'`: Brotli compression (default for most modes)
20
- * - `'none'`: No compression
21
- * @defaultValue `'br'` for most modes
19
+ * - `'zstd'`: Zstandard compression (maximum compression for smallest files)
20
+ * @defaultValue `'zstd'`
22
21
  */
23
- compression?: 'br' | 'none';
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
- export declare function cropAndReconstitute(input: Buffer): Promise<Buffer>;
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 w = info.width;
113
- const h = info.height;
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 [data[i], data[i + 1], data[i + 2], data[i + 3]];
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(input)
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 compressedLines = [];
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 < cw) {
220
- const current = cat(x, y);
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 < cw && eq(cat(nx, y), current))
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 newWidth = Math.max(...compressedLines.map((l) => l.length));
250
- const newHeight = compressedLines.length + 1;
251
- const out = Buffer.alloc(newWidth * newHeight * 4, 0);
252
- for (let i = 0; i < out.length; i += 4)
253
- out[i + 3] = 255;
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 isSecondToLast = y === compressedLines.length - 1;
257
- const startX = 0;
258
- const effectiveLength = isSecondToLast
259
- ? Math.max(0, line.length - 3)
260
- : line.length;
261
- for (let x = 0; x < effectiveLength; x++) {
262
- const i = ((y * newWidth + startX + x) * 4) | 0;
263
- out[i] = line[x][0];
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
- let finalHeight = newHeight;
288
- let finalOut = out;
289
- if (secondToLastIsBlack) {
290
- finalHeight = newHeight - 1;
291
- finalOut = Buffer.alloc(newWidth * finalHeight * 4, 0);
292
- for (let i = 0; i < finalOut.length; i += 4)
293
- finalOut[i + 3] = 255;
294
- for (let y = 0; y < newHeight - 2; y++) {
295
- for (let x = 0; x < newWidth; x++) {
296
- const srcI = ((y * newWidth + x) * 4) | 0;
297
- const dstI = ((y * newWidth + x) * 4) | 0;
298
- finalOut[dstI] = out[srcI];
299
- finalOut[dstI + 1] = out[srcI + 1];
300
- finalOut[dstI + 2] = out[srcI + 2];
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 lastY = finalHeight - 1;
306
- for (let x = 0; x < newWidth; x++) {
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 useBrotli = opts.compression === 'br' ||
350
- (opts.compression === undefined &&
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 dataWithMarkerStart = Buffer.concat([markerStartBytes, paddedData]);
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(dataWithMarkerStart.length / 3);
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 < dataWithMarkerStart.length
469
- ? dataWithMarkerStart[srcIdx + 1]
517
+ srcIdx + 1 < dataWithMarkers.length
518
+ ? dataWithMarkers[srcIdx + 1]
470
519
  : 0;
471
520
  b =
472
- srcIdx + 2 < dataWithMarkerStart.length
473
- ? dataWithMarkerStart[srcIdx + 2]
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: 0,
541
+ compressionLevel: 9,
493
542
  palette: false,
494
- effort: 1,
495
- adaptiveFiltering: false,
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 = tryBrotliDecompress(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, brotli failed: ' + errMsg + ')');
624
- throw new Error('ROX format brotli decompression failed: ' + errMsg);
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 = tryBrotliDecompress(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, brotli failed: ' + errMsg + ')');
678
- throw new DataFormatError('Compact mode brotli decompression failed: ' + errMsg);
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 = tryBrotliDecompress(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 = tryBrotliDecompress(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('Incorrect passphrase (pixel mode, brotli failed: ' +
1067
+ throw new IncorrectPassphraseError(`Incorrect passphrase (screenshot mode, zstd failed: ` +
1011
1068
  errMsg +
1012
1069
  ')');
1013
- throw new DataFormatError('Pixel mode brotli decompression failed: ' + errMsg);
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.0",
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"