roxify 1.1.1 → 1.1.3

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, 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,30 @@ 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.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.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
+ const reconstructedBuffer = await cropAndReconstitute(doubledBuffer, options.debugDir);
191
+ const result = await decodePngToBinary(reconstructedBuffer, options);
189
192
  const decodeTime = Date.now() - startDecode;
190
193
  const resolvedOutput = parsed.output || outputPath || result.meta?.name || 'decoded.bin';
191
194
  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,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,195 +136,131 @@ 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 w = info.width;
126
- const h = info.height;
127
- function at(x, y) {
128
- const i = idxFor(x, y, w);
129
- return [data[i], data[i + 1], data[i + 2], data[i + 3]];
130
- }
131
- let startPoint = null;
132
- for (let y = 0; y < h && !startPoint; y++) {
133
- for (let x = 0; x < w; x++) {
134
- const p = at(x, y);
135
- if (p[0] !== 255 || p[1] !== 0 || p[2] !== 0)
136
- continue;
137
- let nx = x + 1;
138
- while (nx < w && eqRGB(at(nx, y), p))
139
- nx++;
140
- if (nx >= w)
141
- continue;
142
- const a = at(nx, y);
143
- let nx2 = nx + 1;
144
- while (nx2 < w && eqRGB(at(nx2, y), a))
145
- nx2++;
146
- if (nx2 >= w)
147
- continue;
148
- const b = at(nx2, y);
149
- const isRgb = a[0] === 0 &&
150
- a[1] === 255 &&
151
- a[2] === 0 &&
152
- b[0] === 0 &&
153
- b[1] === 0 &&
154
- b[2] === 255;
155
- if (isRgb) {
156
- startPoint = { x, y, type: 'rgb' };
157
- break;
158
- }
159
- }
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'));
160
149
  }
161
- let endPoint = null;
162
- for (let y = h - 1; y >= 0 && !endPoint; y--) {
163
- for (let x = w - 1; x >= 0; x--) {
164
- const p = at(x, y);
165
- if (p[0] !== 255 || p[1] !== 0 || p[2] !== 0)
166
- continue;
167
- let nx = x - 1;
168
- while (nx >= 0 && eqRGB(at(nx, y), p))
169
- nx--;
170
- if (nx < 0)
171
- continue;
172
- const a = at(nx, y);
173
- let nx2 = nx - 1;
174
- while (nx2 >= 0 && eqRGB(at(nx2, y), a))
175
- nx2--;
176
- if (nx2 < 0)
177
- continue;
178
- const b = at(nx2, y);
179
- const isRgbReverse = a[0] === 0 &&
180
- a[1] === 255 &&
181
- a[2] === 0 &&
182
- b[0] === 0 &&
183
- b[1] === 0 &&
184
- b[2] === 255;
185
- if (isRgbReverse) {
186
- endPoint = { x, y, type: 'bgr' };
187
- break;
150
+ const { data: doubledData, info: doubledInfo } = await loadRaw(doubledBuffer);
151
+ const w = doubledInfo.width, h = doubledInfo.height;
152
+ const at = (x, y) => {
153
+ const i = idxFor(x, y, w);
154
+ return [
155
+ doubledData[i],
156
+ doubledData[i + 1],
157
+ doubledData[i + 2],
158
+ doubledData[i + 3],
159
+ ];
160
+ };
161
+ const findPattern = (startX, startY, dirX, dirY, pattern) => {
162
+ for (let y = startY; y >= 0 && y < h; y += dirY) {
163
+ for (let x = startX; x >= 0 && x < w; x += dirX) {
164
+ const p = at(x, y);
165
+ if (p[0] !== 255 || p[1] !== 0 || p[2] !== 0)
166
+ continue;
167
+ let nx = x + dirX;
168
+ while (nx >= 0 && nx < w && eqRGB(at(nx, y), p))
169
+ nx += dirX;
170
+ if (nx < 0 || nx >= w)
171
+ continue;
172
+ const a = at(nx, y);
173
+ let nx2 = nx + dirX;
174
+ while (nx2 >= 0 && nx2 < w && eqRGB(at(nx2, y), a))
175
+ nx2 += dirX;
176
+ if (nx2 < 0 || nx2 >= w)
177
+ continue;
178
+ const b = at(nx2, y);
179
+ if (a[0] === pattern[0][0] &&
180
+ a[1] === pattern[0][1] &&
181
+ a[2] === pattern[0][2] &&
182
+ b[0] === pattern[1][0] &&
183
+ b[1] === pattern[1][1] &&
184
+ b[2] === pattern[1][2]) {
185
+ return { x, y };
186
+ }
188
187
  }
189
188
  }
190
- }
191
- if (!startPoint)
192
- throw new Error('Start pattern (RGB) not found');
193
- if (!endPoint)
194
- throw new Error('End pattern (BGR) not found');
195
- const sx1 = Math.min(startPoint.x, endPoint.x);
196
- const sy1 = Math.min(startPoint.y, endPoint.y);
197
- const sx2 = Math.max(startPoint.x, endPoint.x);
198
- const sy2 = Math.max(startPoint.y, endPoint.y);
199
- const cropW = sx2 - sx1 + 1;
200
- const cropH = sy2 - sy1 + 1;
189
+ return null;
190
+ };
191
+ const startPoint = findPattern(0, 0, 1, 1, [
192
+ [0, 255, 0],
193
+ [0, 0, 255],
194
+ ]);
195
+ const endPoint = findPattern(w - 1, h - 1, -1, -1, [
196
+ [0, 255, 0],
197
+ [0, 0, 255],
198
+ ]);
199
+ if (!startPoint || !endPoint)
200
+ throw new Error('Patterns not found');
201
+ const sx1 = Math.min(startPoint.x, endPoint.x), sy1 = Math.min(startPoint.y, endPoint.y);
202
+ const sx2 = Math.max(startPoint.x, endPoint.x), sy2 = Math.max(startPoint.y, endPoint.y);
203
+ const cropW = sx2 - sx1 + 1, cropH = sy2 - sy1 + 1;
201
204
  if (cropW <= 0 || cropH <= 0)
202
205
  throw new Error('Invalid crop dimensions');
203
- const cropped = await sharp(input)
206
+ const cropped = await sharp(doubledBuffer)
204
207
  .extract({ left: sx1, top: sy1, width: cropW, height: cropH })
205
208
  .png()
206
209
  .toBuffer();
207
- const { data: cdata, info: cinfo } = await sharp(cropped)
208
- .ensureAlpha()
209
- .raw()
210
- .toBuffer({ resolveWithObject: true });
211
- const cw = cinfo.width;
212
- const ch = cinfo.height;
213
- function cat(x, y) {
214
- const i = idxFor(x, y, cw);
215
- return [cdata[i], cdata[i + 1], cdata[i + 2], cdata[i + 3]];
216
- }
217
- function eq(a, b) {
218
- return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3];
219
- }
220
- function lineEq(l1, l2) {
221
- if (l1.length !== l2.length)
222
- return false;
223
- for (let i = 0; i < l1.length; i++)
224
- if (!eq(l1[i], l2[i]))
225
- return false;
226
- return true;
227
- }
228
- const newWidth = cw;
229
- const newHeight = ch + 1;
210
+ const { data: cdata, info: cinfo } = await loadRaw(cropped);
211
+ const cw = cinfo.width, ch = cinfo.height;
212
+ const newWidth = cw, newHeight = ch + 1;
230
213
  const out = Buffer.alloc(newWidth * newHeight * 4, 0);
231
214
  for (let i = 0; i < out.length; i += 4)
232
215
  out[i + 3] = 255;
233
216
  for (let y = 0; y < ch; y++) {
234
217
  for (let x = 0; x < cw; x++) {
235
- const srcI = ((y * cw + x) * 4) | 0;
236
- const dstI = ((y * newWidth + x) * 4) | 0;
218
+ const srcI = (y * cw + x) * 4;
219
+ const dstI = (y * newWidth + x) * 4;
237
220
  out[dstI] = cdata[srcI];
238
221
  out[dstI + 1] = cdata[srcI + 1];
239
222
  out[dstI + 2] = cdata[srcI + 2];
240
223
  out[dstI + 3] = cdata[srcI + 3];
241
224
  }
242
225
  }
243
- if (cw >= 3) {
244
- const targetY = ch - 1;
245
- for (let x = cw - 3; x < cw; x++) {
246
- const i = ((targetY * newWidth + x) * 4) | 0;
247
- out[i] = 0;
248
- out[i + 1] = 0;
249
- out[i + 2] = 0;
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
- }
262
- }
263
- const lastY = ch;
264
226
  for (let x = 0; x < newWidth; x++) {
265
- const i = ((lastY * newWidth + x) * 4) | 0;
266
- out[i] = 0;
267
- out[i + 1] = 0;
268
- out[i + 2] = 0;
227
+ const i = ((ch - 1) * newWidth + x) * 4;
228
+ out[i] = out[i + 1] = out[i + 2] = 0;
269
229
  out[i + 3] = 255;
230
+ const j = (ch * newWidth + x) * 4;
231
+ out[j] = out[j + 1] = out[j + 2] = 0;
232
+ out[j + 3] = 255;
270
233
  }
271
234
  if (newWidth >= 3) {
272
235
  const bgrStart = newWidth - 3;
273
- let i = ((lastY * newWidth + bgrStart) * 4) | 0;
274
- out[i] = 0;
275
- out[i + 1] = 0;
276
- out[i + 2] = 255;
277
- out[i + 3] = 255;
278
- i = ((lastY * newWidth + bgrStart + 1) * 4) | 0;
279
- out[i] = 0;
280
- out[i + 1] = 255;
281
- out[i + 2] = 0;
282
- out[i + 3] = 255;
283
- i = ((lastY * newWidth + bgrStart + 2) * 4) | 0;
284
- out[i] = 255;
285
- out[i + 1] = 0;
286
- out[i + 2] = 0;
287
- out[i + 3] = 255;
236
+ const bgr = [
237
+ [0, 0, 255],
238
+ [0, 255, 0],
239
+ [255, 0, 0],
240
+ ];
241
+ for (let k = 0; k < 3; k++) {
242
+ const i = (ch * newWidth + bgrStart + k) * 4;
243
+ out[i] = bgr[k][0];
244
+ out[i + 1] = bgr[k][1];
245
+ out[i + 2] = bgr[k][2];
246
+ out[i + 3] = 255;
247
+ }
288
248
  }
289
- function getPixel(x, y) {
290
- const i = ((y * newWidth + x) * 4) | 0;
249
+ const getPixel = (x, y) => {
250
+ const i = (y * newWidth + x) * 4;
291
251
  return [out[i], out[i + 1], out[i + 2], out[i + 3]];
292
- }
252
+ };
293
253
  const compressedLines = [];
294
254
  for (let y = 0; y < newHeight; y++) {
295
255
  const line = [];
296
- let x = 0;
297
- while (x < newWidth) {
298
- const current = getPixel(x, y);
299
- if (current[0] === 0 && current[1] === 0 && current[2] === 0) {
300
- x++;
301
- continue;
302
- }
303
- line.push(current);
304
- let nx = x + 1;
305
- while (nx < newWidth && eq(getPixel(nx, y), current))
306
- nx++;
307
- x = nx;
308
- }
309
- if (line.length === 0)
310
- continue;
311
- if (compressedLines.length === 0 ||
312
- !lineEq(compressedLines[compressedLines.length - 1], line))
256
+ for (let x = 0; x < newWidth; x++)
257
+ line.push(getPixel(x, y));
258
+ const isAllBlack = line.every((p) => p[0] === 0 && p[1] === 0 && p[2] === 0 && p[3] === 255);
259
+ if (!isAllBlack &&
260
+ (compressedLines.length === 0 ||
261
+ !line.every((p, i) => p.every((v, j) => v === compressedLines[compressedLines.length - 1][i][j])))) {
313
262
  compressedLines.push(line);
263
+ }
314
264
  }
315
265
  if (compressedLines.length === 0) {
316
266
  return sharp({
@@ -324,21 +274,109 @@ export async function cropAndReconstitute(input) {
324
274
  .png()
325
275
  .toBuffer();
326
276
  }
327
- const finalWidth = Math.max(...compressedLines.map((l) => l.length));
328
- const finalHeight = compressedLines.length;
329
- const finalOut = Buffer.alloc(finalWidth * finalHeight * 4, 0);
277
+ let finalWidth = newWidth, finalHeight = compressedLines.length;
278
+ let finalOut = Buffer.alloc(finalWidth * finalHeight * 4, 0);
330
279
  for (let i = 0; i < finalOut.length; i += 4)
331
280
  finalOut[i + 3] = 255;
332
- for (let y = 0; y < compressedLines.length; y++) {
333
- const line = compressedLines[y];
334
- const isLastLine = y === compressedLines.length - 1;
335
- const startX = isLastLine ? finalWidth - line.length : 0;
336
- for (let x = 0; x < line.length; x++) {
337
- const i = ((y * finalWidth + startX + x) * 4) | 0;
338
- finalOut[i] = line[x][0];
339
- finalOut[i + 1] = line[x][1];
340
- finalOut[i + 2] = line[x][2];
341
- finalOut[i + 3] = line[x][3] === 0 ? 255 : line[x][3];
281
+ for (let y = 0; y < finalHeight; y++) {
282
+ for (let x = 0; x < finalWidth; x++) {
283
+ const i = (y * finalWidth + x) * 4;
284
+ finalOut[i] = compressedLines[y][x][0];
285
+ finalOut[i + 1] = compressedLines[y][x][1];
286
+ finalOut[i + 2] = compressedLines[y][x][2];
287
+ finalOut[i + 3] = compressedLines[y][x][3] || 255;
288
+ }
289
+ }
290
+ if (finalHeight >= 1 && finalWidth >= 3) {
291
+ const lastY = finalHeight - 1;
292
+ for (let k = 0; k < 3; k++) {
293
+ const i = (lastY * finalWidth + finalWidth - 3 + k) * 4;
294
+ finalOut[i] = finalOut[i + 1] = finalOut[i + 2] = 0;
295
+ finalOut[i + 3] = 255;
296
+ }
297
+ }
298
+ if (finalWidth >= 2) {
299
+ const kept = [];
300
+ for (let x = 0; x < finalWidth; x++) {
301
+ if (kept.length === 0) {
302
+ kept.push(x);
303
+ continue;
304
+ }
305
+ const prevX = kept[kept.length - 1];
306
+ let same = true;
307
+ for (let y = 0; y < finalHeight; y++) {
308
+ const ia = (y * finalWidth + prevX) * 4, ib = (y * finalWidth + x) * 4;
309
+ if (finalOut[ia] !== finalOut[ib] ||
310
+ finalOut[ia + 1] !== finalOut[ib + 1] ||
311
+ finalOut[ia + 2] !== finalOut[ib + 2] ||
312
+ finalOut[ia + 3] !== finalOut[ib + 3]) {
313
+ same = false;
314
+ break;
315
+ }
316
+ }
317
+ if (!same)
318
+ kept.push(x);
319
+ }
320
+ if (kept.length !== finalWidth) {
321
+ const newFinalWidth = kept.length;
322
+ const newOut = Buffer.alloc(newFinalWidth * finalHeight * 4, 0);
323
+ for (let i = 0; i < newOut.length; i += 4)
324
+ newOut[i + 3] = 255;
325
+ for (let nx = 0; nx < kept.length; nx++) {
326
+ const sx = kept[nx];
327
+ for (let y = 0; y < finalHeight; y++) {
328
+ const srcI = (y * finalWidth + sx) * 4, dstI = (y * newFinalWidth + nx) * 4;
329
+ newOut[dstI] = finalOut[srcI];
330
+ newOut[dstI + 1] = finalOut[srcI + 1];
331
+ newOut[dstI + 2] = finalOut[srcI + 2];
332
+ newOut[dstI + 3] = finalOut[srcI + 3];
333
+ }
334
+ }
335
+ finalOut = newOut;
336
+ finalWidth = newFinalWidth;
337
+ }
338
+ }
339
+ if (finalHeight >= 2 && finalWidth >= 3) {
340
+ const secondLastY = finalHeight - 2;
341
+ const bgrSeq = [
342
+ [0, 0, 255],
343
+ [0, 255, 0],
344
+ [255, 0, 0],
345
+ ];
346
+ let hasBGR = true;
347
+ for (let k = 0; k < 3; k++) {
348
+ const i = (secondLastY * finalWidth + finalWidth - 3 + k) * 4;
349
+ if (finalOut[i] !== bgrSeq[k][0] ||
350
+ finalOut[i + 1] !== bgrSeq[k][1] ||
351
+ finalOut[i + 2] !== bgrSeq[k][2]) {
352
+ hasBGR = false;
353
+ break;
354
+ }
355
+ }
356
+ if (hasBGR) {
357
+ for (let k = 0; k < 3; k++) {
358
+ const i = (secondLastY * finalWidth + finalWidth - 3 + k) * 4;
359
+ finalOut[i] = finalOut[i + 1] = finalOut[i + 2] = 0;
360
+ finalOut[i + 3] = 255;
361
+ }
362
+ }
363
+ }
364
+ if (finalHeight >= 1 && finalWidth >= 1) {
365
+ const lastYFinal = finalHeight - 1;
366
+ const bgrSeq = [
367
+ [0, 0, 255],
368
+ [0, 255, 0],
369
+ [255, 0, 0],
370
+ ];
371
+ for (let k = 0; k < 3; k++) {
372
+ const sx = finalWidth - 3 + k;
373
+ if (sx >= 0) {
374
+ const i = (lastYFinal * finalWidth + sx) * 4;
375
+ finalOut[i] = bgrSeq[k][0];
376
+ finalOut[i + 1] = bgrSeq[k][1];
377
+ finalOut[i + 2] = bgrSeq[k][2];
378
+ finalOut[i + 3] = 255;
379
+ }
342
380
  }
343
381
  }
344
382
  return sharp(finalOut, {
@@ -359,14 +397,8 @@ export async function encodeBinaryToPng(input, opts = {}) {
359
397
  let payload = Buffer.concat([MAGIC, input]);
360
398
  const brQuality = typeof opts.brQuality === 'number' ? opts.brQuality : 11;
361
399
  const mode = opts.mode === undefined ? 'screenshot' : opts.mode;
362
- const useBrotli = opts.compression === 'br' ||
363
- (opts.compression === undefined &&
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
- }
400
+ const compression = opts.compression || 'zstd';
401
+ payload = Buffer.from(await zstdCompress(payload, 22));
370
402
  if (opts.passphrase && !opts.encrypt) {
371
403
  opts.encrypt = 'aes';
372
404
  }
@@ -443,9 +475,14 @@ export async function encodeBinaryToPng(input, opts = {}) {
443
475
  ? Buffer.concat([dataWithoutMarkers, Buffer.alloc(padding)])
444
476
  : dataWithoutMarkers;
445
477
  const markerStartBytes = colorsToBytes(MARKER_START);
446
- const dataWithMarkerStart = Buffer.concat([markerStartBytes, paddedData]);
478
+ const compressionMarkerBytes = colorsToBytes(COMPRESSION_MARKERS.zstd);
479
+ const dataWithMarkers = Buffer.concat([
480
+ markerStartBytes,
481
+ compressionMarkerBytes,
482
+ paddedData,
483
+ ]);
447
484
  const bytesPerPixel = 3;
448
- const dataPixels = Math.ceil(dataWithMarkerStart.length / 3);
485
+ const dataPixels = Math.ceil(dataWithMarkers.length / 3);
449
486
  let logicalWidth = Math.ceil(Math.sqrt(dataPixels));
450
487
  if (logicalWidth < MARKER_END.length) {
451
488
  logicalWidth = MARKER_END.length;
@@ -455,7 +492,7 @@ export async function encodeBinaryToPng(input, opts = {}) {
455
492
  const spaceInLastRow = pixelsInLastRow === 0 ? logicalWidth : logicalWidth - pixelsInLastRow;
456
493
  const needsExtraRow = spaceInLastRow < MARKER_END.length;
457
494
  const logicalHeight = needsExtraRow ? dataRows + 1 : dataRows;
458
- const scale = 2;
495
+ const scale = 1;
459
496
  const width = logicalWidth * scale;
460
497
  const height = logicalHeight * scale;
461
498
  const raw = Buffer.alloc(width * height * bytesPerPixel);
@@ -473,17 +510,14 @@ export async function encodeBinaryToPng(input, opts = {}) {
473
510
  else if (ly < dataRows ||
474
511
  (ly === dataRows && linearIdx < dataPixels)) {
475
512
  const srcIdx = linearIdx * 3;
476
- r =
477
- srcIdx < dataWithMarkerStart.length
478
- ? dataWithMarkerStart[srcIdx]
479
- : 0;
513
+ r = srcIdx < dataWithMarkers.length ? dataWithMarkers[srcIdx] : 0;
480
514
  g =
481
- srcIdx + 1 < dataWithMarkerStart.length
482
- ? dataWithMarkerStart[srcIdx + 1]
515
+ srcIdx + 1 < dataWithMarkers.length
516
+ ? dataWithMarkers[srcIdx + 1]
483
517
  : 0;
484
518
  b =
485
- srcIdx + 2 < dataWithMarkerStart.length
486
- ? dataWithMarkerStart[srcIdx + 2]
519
+ srcIdx + 2 < dataWithMarkers.length
520
+ ? dataWithMarkers[srcIdx + 2]
487
521
  : 0;
488
522
  }
489
523
  for (let sy = 0; sy < scale; sy++) {
@@ -628,13 +662,13 @@ export async function decodePngToBinary(pngBuf, opts = {}) {
628
662
  const rawPayload = d.slice(idx);
629
663
  let payload = tryDecryptIfNeeded(rawPayload, opts.passphrase);
630
664
  try {
631
- payload = tryBrotliDecompress(payload);
665
+ payload = await tryZstdDecompress(payload);
632
666
  }
633
667
  catch (e) {
634
668
  const errMsg = e instanceof Error ? e.message : String(e);
635
669
  if (opts.passphrase)
636
- throw new Error('Incorrect passphrase (ROX format, brotli failed: ' + errMsg + ')');
637
- throw new Error('ROX format brotli decompression failed: ' + errMsg);
670
+ throw new Error('Incorrect passphrase (ROX format, zstd failed: ' + errMsg + ')');
671
+ throw new Error('ROX format zstd decompression failed: ' + errMsg);
638
672
  }
639
673
  if (!payload.slice(0, MAGIC.length).equals(MAGIC)) {
640
674
  throw new Error('Invalid ROX format (ROX direct: missing ROX1 magic after decompression)');
@@ -682,13 +716,13 @@ export async function decodePngToBinary(pngBuf, opts = {}) {
682
716
  throw new DataFormatError('Compact mode payload empty');
683
717
  let payload = tryDecryptIfNeeded(rawPayload, opts.passphrase);
684
718
  try {
685
- payload = tryBrotliDecompress(payload);
719
+ payload = await tryZstdDecompress(payload);
686
720
  }
687
721
  catch (e) {
688
722
  const errMsg = e instanceof Error ? e.message : String(e);
689
723
  if (opts.passphrase)
690
- throw new IncorrectPassphraseError('Incorrect passphrase (compact mode, brotli failed: ' + errMsg + ')');
691
- throw new DataFormatError('Compact mode brotli decompression failed: ' + errMsg);
724
+ throw new IncorrectPassphraseError('Incorrect passphrase (compact mode, zstd failed: ' + errMsg + ')');
725
+ throw new DataFormatError('Compact mode zstd decompression failed: ' + errMsg);
692
726
  }
693
727
  if (!payload.slice(0, MAGIC.length).equals(MAGIC)) {
694
728
  throw new DataFormatError('Invalid ROX format (compact mode: missing ROX1 magic after decompression)');
@@ -748,7 +782,7 @@ export async function decodePngToBinary(pngBuf, opts = {}) {
748
782
  logicalData = rawRGB;
749
783
  }
750
784
  else {
751
- const reconstructed = await cropAndReconstitute(data);
785
+ const reconstructed = await cropAndReconstitute(data, opts.debugDir);
752
786
  const { data: rdata, info: rinfo } = await sharp(reconstructed)
753
787
  .ensureAlpha()
754
788
  .raw()
@@ -794,14 +828,9 @@ export async function decodePngToBinary(pngBuf, opts = {}) {
794
828
  const rawPayload = logicalData.slice(idx, idx + payloadLen);
795
829
  let payload = tryDecryptIfNeeded(rawPayload, opts.passphrase);
796
830
  try {
797
- payload = tryBrotliDecompress(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);
831
+ payload = await tryZstdDecompress(payload);
804
832
  }
833
+ catch (e) { }
805
834
  if (!payload.slice(0, MAGIC.length).equals(MAGIC)) {
806
835
  throw new DataFormatError('Invalid ROX format (pixel mode: missing ROX1 magic after decompression)');
807
836
  }
@@ -931,6 +960,19 @@ export async function decodePngToBinary(pngBuf, opts = {}) {
931
960
  throw new Error('Marker START not found - image format not supported');
932
961
  }
933
962
  }
963
+ let compression = 'zstd';
964
+ if (gridFromStart.length > MARKER_START.length) {
965
+ const compPixel = gridFromStart[MARKER_START.length];
966
+ if (compPixel.r === 0 && compPixel.g === 255 && compPixel.b === 0) {
967
+ compression = 'zstd';
968
+ }
969
+ else {
970
+ compression = 'zstd';
971
+ }
972
+ }
973
+ if (process.env.ROX_DEBUG) {
974
+ console.log(`DEBUG: Detected compression: ${compression}`);
975
+ }
934
976
  let endStartIdx = -1;
935
977
  const lastLineStart = (logicalHeight - 1) * logicalWidth;
936
978
  const endMarkerStartCol = logicalWidth - MARKER_END.length;
@@ -966,7 +1008,7 @@ export async function decodePngToBinary(pngBuf, opts = {}) {
966
1008
  }
967
1009
  endStartIdx = gridFromStart.length;
968
1010
  }
969
- const dataGrid = gridFromStart.slice(MARKER_START.length, endStartIdx);
1011
+ const dataGrid = gridFromStart.slice(MARKER_START.length + 1, endStartIdx);
970
1012
  const pixelBytes = Buffer.alloc(dataGrid.length * 3);
971
1013
  for (let i = 0; i < dataGrid.length; i++) {
972
1014
  pixelBytes[i * 3] = dataGrid[i].r;
@@ -1015,15 +1057,15 @@ export async function decodePngToBinary(pngBuf, opts = {}) {
1015
1057
  const rawPayload = pixelBytes.slice(idx, idx + payloadLen);
1016
1058
  let payload = tryDecryptIfNeeded(rawPayload, opts.passphrase);
1017
1059
  try {
1018
- payload = tryBrotliDecompress(payload);
1060
+ payload = await tryZstdDecompress(payload);
1019
1061
  }
1020
1062
  catch (e) {
1021
1063
  const errMsg = e instanceof Error ? e.message : String(e);
1022
1064
  if (opts.passphrase)
1023
- throw new IncorrectPassphraseError('Incorrect passphrase (pixel mode, brotli failed: ' +
1065
+ throw new IncorrectPassphraseError(`Incorrect passphrase (screenshot mode, zstd failed: ` +
1024
1066
  errMsg +
1025
1067
  ')');
1026
- throw new DataFormatError('Pixel mode brotli decompression failed: ' + errMsg);
1068
+ throw new DataFormatError(`Screenshot mode zstd decompression failed: ` + errMsg);
1027
1069
  }
1028
1070
  if (!payload.slice(0, MAGIC.length).equals(MAGIC)) {
1029
1071
  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.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.3",
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"