roxify 1.1.4 → 1.1.6

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
@@ -15,6 +15,8 @@ Key benefits:
15
15
  - **Code Efficiency**: Hyper-efficient for compressing source code, reducing file sizes dramatically.
16
16
  - **Obfuscation & Security**: Obfuscate code or lock files with AES-256-GCM encryption, more compact than password-protected ZIPs.
17
17
  - **Visual Data Indicator**: PNG size visually represents embedded data size, providing an intuitive overview.
18
+ - **Archive Support**: Pack directories into archives, list contents without decoding, and extract individual files selectively.
19
+ - **Central Directory**: Access file lists without passphrase, even for encrypted archives.
18
20
 
19
21
  ## Installation
20
22
 
@@ -37,8 +39,9 @@ If no output name is provided:
37
39
 
38
40
  **Commands:**
39
41
 
40
- - `encode <input> [output]` — Encode file to PNG
41
- - `decode <input> [output]` — Decode PNG to file
42
+ - `encode <input>... [output]` — Encode file(s)/directory to PNG
43
+ - `decode <input> [output]` — Decode PNG to file(s)
44
+ - `list <input>` — List files in archive without decoding
42
45
 
43
46
  **Options:**
44
47
 
@@ -46,6 +49,7 @@ If no output name is provided:
46
49
  - `-m, --mode <mode>` — Encoding mode: `screenshot` (default), `pixel`, `compact`, `chunk`
47
50
  - `-q, --quality <0-22>` — Roxify compression level (default: 22)
48
51
  - `--no-compress` — Disable compression
52
+ - `--files <list>` — Extract only specified files (comma-separated, for archives)
49
53
  - `-v, --verbose` — Show detailed errors
50
54
 
51
55
  Run `npx rox help` for full options.
@@ -53,46 +57,109 @@ Run `npx rox help` for full options.
53
57
  ## API Usage
54
58
 
55
59
  ```js
56
- import { encodeBinaryToPng, decodePngToBinary } from 'roxify';
60
+ import { encodeBinaryToPng, decodePngToBinary, listFilesInPng } from 'roxify';
57
61
 
62
+ // Encode a file
58
63
  const data = Buffer.from('Hello world');
59
64
  const png = await encodeBinaryToPng(data, {
60
65
  mode: 'screenshot',
61
66
  name: 'message.txt',
62
67
  });
63
68
 
69
+ // Decode
64
70
  const { buf, meta } = await decodePngToBinary(png);
65
71
  console.log(buf.toString('utf8'));
66
72
  console.log(meta?.name);
73
+
74
+ // List files in archive
75
+ const files = listFilesInPng(png);
76
+ console.log(files);
77
+
78
+ // Selective extraction
79
+ const result = await decodePngToBinary(png, { files: ['file1.txt'] });
80
+ if (result.files) {
81
+ // result.files contains only the selected files
82
+ }
83
+ ```
84
+
85
+ ## Example: Progress Logging
86
+
87
+ ```js
88
+ import { encodeBinaryToPng, decodePngToBinary } from 'roxify';
89
+
90
+ const data = Buffer.from('Large data to encode...');
91
+
92
+ // Encode with progress logging
93
+ const png = await encodeBinaryToPng(data, {
94
+ onProgress: (info) => {
95
+ console.log(`Encoding phase: ${info.phase}`);
96
+ if (info.loaded && info.total) {
97
+ const percent = Math.round((info.loaded / info.total) * 100);
98
+ console.log(`Progress: ${percent}% (${info.loaded}/${info.total} bytes)`);
99
+ }
100
+ },
101
+ });
102
+
103
+ // Decode with progress logging
104
+ const { buf } = await decodePngToBinary(png, {
105
+ onProgress: (info) => {
106
+ console.log(`Decoding phase: ${info.phase}`);
107
+ },
108
+ });
67
109
  ```
68
110
 
69
111
  **API:**
70
112
 
71
113
  - `encodeBinaryToPng(input: Buffer, opts?: EncodeOptions): Promise<Buffer>`
72
- - `decodePngToBinary(pngBuf: Buffer): Promise<{ buf: Buffer, meta?: { name?: string } }>`
114
+ - `decodePngToBinary(pngBuf: Buffer, opts?: DecodeOptions): Promise<DecodeResult>`
115
+ - `listFilesInPng(pngBuf: Buffer): string[] | null`
73
116
 
74
- **Options:**
117
+ **EncodeOptions:**
75
118
 
76
119
  - `mode` — `'screenshot'` | `'pixel'` | `'compact'` | `'chunk'` (default: `'screenshot'`)
77
120
  - `name` — Original filename (embedded as metadata)
78
121
  - `passphrase` — Encryption passphrase (uses AES-256-GCM)
79
122
  - `compression` — `'Roxify'` | `'none'` (default: `'Roxify'`)
80
123
  - `brQuality` — Roxify compression level 0-22 (default: 22)
124
+ - `showProgress` — Display progress bar (default: `false`)
125
+ - `onProgress` — Callback for progress updates: `(info: { phase: string; loaded?: number; total?: number }) => void`
126
+ - `includeFileList` — Include file list for archives (default: `true` for directories)
127
+
128
+ **DecodeOptions:**
81
129
 
82
- ## Example: Express Endpoint
130
+ - `passphrase` Decryption passphrase
131
+ - `files` — List of files to extract selectively (for archives)
132
+ - `showProgress` — Display progress bar (default: `false`)
133
+ - `onProgress` — Callback for progress updates: `(info: { phase: string; loaded?: number; total?: number }) => void`
134
+
135
+ **DecodeResult:**
136
+
137
+ - `buf?: Buffer` — Decoded data (if not selective extraction)
138
+ - `files?: PackedFile[]` — Extracted files (if selective extraction)
139
+ - `meta?: { name?: string }` — Metadata
140
+
141
+ ## Example: Archive with Selective Extraction
83
142
 
84
143
  ```js
85
- import express from 'express';
86
- import { encodeBinaryToPng } from 'roxify';
87
-
88
- const app = express();
89
- app.get('/payload.png', async (req, res) => {
90
- const payload = Buffer.from('Embedded data');
91
- const png = await encodeBinaryToPng(payload, { mode: 'screenshot' });
92
- res.setHeader('Content-Type', 'image/png');
93
- res.send(png);
144
+ import { encodeBinaryToPng, decodePngToBinary, listFilesInPng } from 'roxify';
145
+
146
+ // Pack a directory
147
+ const fs = require('fs');
148
+ const dirData = packPaths(['myfolder']); // From pack.js
149
+ const png = await encodeBinaryToPng(dirData.buf, {
150
+ includeFileList: true,
151
+ fileList: dirData.list,
94
152
  });
95
- app.listen(3000);
153
+
154
+ // List files without decoding
155
+ const files = listFilesInPng(png);
156
+ console.log('Files:', files);
157
+
158
+ // Extract only one file
159
+ const result = await decodePngToBinary(png, { files: ['myfolder/file.txt'] });
160
+ if (result.files) {
161
+ fs.writeFileSync('extracted.txt', result.files[0].buf);
162
+ }
96
163
  ```
97
164
 
98
165
  ## Requirements
package/dist/cli.js CHANGED
@@ -1,8 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync, writeFileSync } from 'fs';
3
- import { basename, dirname, resolve } from 'path';
4
- import { DataFormatError, decodePngToBinary, encodeBinaryToPng, IncorrectPassphraseError, PassphraseRequiredError, } from './index.js';
5
- const VERSION = '1.1.4';
2
+ import cliProgress from 'cli-progress';
3
+ import { mkdirSync, readFileSync, statSync, writeFileSync } from 'fs';
4
+ import { basename, dirname, join, resolve } from 'path';
5
+ import { DataFormatError, decodePngToBinary, encodeBinaryToPng, IncorrectPassphraseError, listFilesInPng, PassphraseRequiredError, } from './index.js';
6
+ import { packPaths, unpackBuffer } from './pack.js';
7
+ const VERSION = '1.1.5';
6
8
  function showHelp() {
7
9
  console.log(`
8
10
  ROX CLI — Encode/decode binary in PNG
@@ -11,8 +13,9 @@ Usage:
11
13
  npx rox <command> [options]
12
14
 
13
15
  Commands:
14
- encode <input> [output] Encode file to PNG
15
- decode <input> [output] Decode PNG to original file
16
+ encode <input>... [output] Encode one or more files/directories into a PNG
17
+ decode <input> [output] Decode PNG to original file
18
+ list <input> List files in a Rox PNG archive
16
19
 
17
20
  Options:
18
21
  -p, --passphrase <pass> Use passphrase (AES-256-GCM)
@@ -21,6 +24,7 @@ Options:
21
24
  -e, --encrypt <type> auto|aes|xor|none
22
25
  --no-compress Disable compression
23
26
  -o, --output <path> Output file path
27
+ --files <list> Extract only specified files (comma-separated)
24
28
  --view-reconst Export the reconstituted PNG for debugging
25
29
  --debug Export debug images (doubled.png, reconstructed.png)
26
30
  -v, --verbose Show detailed errors
@@ -55,6 +59,10 @@ function parseArgs(args) {
55
59
  parsed.debugDir = args[i + 1];
56
60
  i += 2;
57
61
  }
62
+ else if (key === 'files') {
63
+ parsed.files = args[i + 1].split(',');
64
+ i += 2;
65
+ }
58
66
  else {
59
67
  const value = args[i + 1];
60
68
  parsed[key] = value;
@@ -107,25 +115,93 @@ function parseArgs(args) {
107
115
  }
108
116
  async function encodeCommand(args) {
109
117
  const parsed = parseArgs(args);
110
- const [inputPath, outputPath] = parsed._;
111
- if (!inputPath) {
118
+ const inputPaths = parsed.output
119
+ ? parsed._
120
+ : parsed._.length > 1
121
+ ? parsed._.slice(0, -1)
122
+ : parsed._;
123
+ const outputPath = parsed.output
124
+ ? undefined
125
+ : parsed._.length > 1
126
+ ? parsed._[parsed._.length - 1]
127
+ : undefined;
128
+ const firstInput = inputPaths[0];
129
+ if (!firstInput) {
130
+ console.log(' ');
112
131
  console.error('Error: Input file required');
113
132
  console.log('Usage: npx rox encode <input> [output] [options]');
114
133
  process.exit(1);
115
134
  }
116
- const resolvedInput = resolve(inputPath);
117
- const resolvedOutput = parsed.output || outputPath || inputPath.replace(/(\.[^.]+)?$/, '.png');
135
+ const resolvedInputs = inputPaths.map((p) => resolve(p));
136
+ const resolvedOutput = parsed.output ||
137
+ outputPath ||
138
+ (inputPaths.length === 1
139
+ ? firstInput.replace(/(\.[^.]+)?$/, '.png')
140
+ : 'archive.png');
141
+ let options = {};
118
142
  try {
119
- console.log(`Reading: ${resolvedInput}`);
120
- const startRead = Date.now();
121
- const inputBuffer = readFileSync(resolvedInput);
122
- const readTime = Date.now() - startRead;
123
- console.log(`Read ${(inputBuffer.length / 1024 / 1024).toFixed(2)} MB in ${readTime}ms`);
124
- const options = {
143
+ let inputBuffer;
144
+ let displayName;
145
+ if (inputPaths.length > 1) {
146
+ console.log(`Packing ${inputPaths.length} inputs...`);
147
+ const bar = new cliProgress.SingleBar({
148
+ format: ' {bar} {percentage}% | {step} | {mbValue}/{mbTotal} MB',
149
+ }, cliProgress.Presets.shades_classic);
150
+ let totalBytes = 0;
151
+ const onProgress = (readBytes, total) => {
152
+ if (totalBytes === 0) {
153
+ totalBytes = total;
154
+ bar.start(totalBytes, 0, {
155
+ step: 'Reading files',
156
+ mbValue: '0.00',
157
+ mbTotal: (totalBytes / 1024 / 1024).toFixed(2),
158
+ });
159
+ }
160
+ bar.update(readBytes, {
161
+ step: 'Reading files',
162
+ mbValue: (readBytes / 1024 / 1024).toFixed(2),
163
+ });
164
+ };
165
+ const packResult = packPaths(inputPaths, undefined, onProgress);
166
+ bar.stop();
167
+ inputBuffer = packResult.buf;
168
+ console.log(`Packed ${packResult.list.length} files -> ${(inputBuffer.length /
169
+ 1024 /
170
+ 1024).toFixed(2)} MB`);
171
+ displayName = parsed.outputName || 'archive';
172
+ options.includeFileList = true;
173
+ options.fileList = packResult.list;
174
+ }
175
+ else {
176
+ const resolvedInput = resolvedInputs[0];
177
+ console.log(' ');
178
+ console.log(`Reading: ${resolvedInput}`);
179
+ console.log(' ');
180
+ const st = statSync(resolvedInput);
181
+ if (st.isDirectory()) {
182
+ console.log(`Packing directory...`);
183
+ const packResult = packPaths([resolvedInput]);
184
+ inputBuffer = packResult.buf;
185
+ console.log(`Packed ${packResult.list.length} files -> ${(inputBuffer.length /
186
+ 1024 /
187
+ 1024).toFixed(2)} MB`);
188
+ displayName = parsed.outputName || basename(resolvedInput);
189
+ options.includeFileList = true;
190
+ options.fileList = packResult.list;
191
+ }
192
+ else {
193
+ const startRead = Date.now();
194
+ inputBuffer = readFileSync(resolvedInput);
195
+ const readTime = Date.now() - startRead;
196
+ console.log('');
197
+ displayName = basename(resolvedInput);
198
+ }
199
+ }
200
+ Object.assign(options, {
125
201
  mode: parsed.mode || 'screenshot',
126
- name: basename(resolvedInput),
202
+ name: displayName,
127
203
  brQuality: parsed.quality !== undefined ? parsed.quality : 11,
128
- };
204
+ });
129
205
  if (parsed.noCompress) {
130
206
  options.compression = 'none';
131
207
  }
@@ -133,10 +209,45 @@ async function encodeCommand(args) {
133
209
  options.passphrase = parsed.passphrase;
134
210
  options.encrypt = parsed.encrypt || 'aes';
135
211
  }
136
- console.log(`Encoding ${basename(resolvedInput)} -> ${resolvedOutput}`);
212
+ console.log(`Encoding ${displayName} -> ${resolvedOutput}\n`);
213
+ const encodeBar = new cliProgress.SingleBar({
214
+ format: ' {bar} {percentage}% | {step} | {elapsed}s',
215
+ }, cliProgress.Presets.shades_classic);
216
+ let totalMB = Math.max(1, Math.round(inputBuffer.length / 1024 / 1024));
217
+ encodeBar.start(100, 0, {
218
+ step: 'Starting',
219
+ elapsed: '0',
220
+ });
137
221
  const startEncode = Date.now();
222
+ options.onProgress = (info) => {
223
+ let pct = 0;
224
+ if (info.phase === 'compress_progress') {
225
+ pct = (info.loaded / info.total) * 50;
226
+ }
227
+ else if (info.phase === 'compress_done') {
228
+ pct = 50;
229
+ }
230
+ else if (info.phase === 'encrypt_done') {
231
+ pct = 80;
232
+ }
233
+ else if (info.phase === 'png_gen') {
234
+ pct = 90;
235
+ }
236
+ else if (info.phase === 'done') {
237
+ pct = 100;
238
+ }
239
+ encodeBar.update(Math.floor(pct), {
240
+ step: info.phase.replace('_', ' '),
241
+ elapsed: String(Math.floor((Date.now() - startEncode) / 1000)),
242
+ });
243
+ };
138
244
  const output = await encodeBinaryToPng(inputBuffer, options);
139
245
  const encodeTime = Date.now() - startEncode;
246
+ encodeBar.update(100, {
247
+ step: 'done',
248
+ elapsed: String(Math.floor(encodeTime / 1000)),
249
+ });
250
+ encodeBar.stop();
140
251
  writeFileSync(resolvedOutput, output);
141
252
  const outputSize = (output.length / 1024 / 1024).toFixed(2);
142
253
  const inputSize = (inputBuffer.length / 1024 / 1024).toFixed(2);
@@ -146,8 +257,10 @@ async function encodeCommand(args) {
146
257
  console.log(` Output: ${outputSize} MB (${ratio}% of original)`);
147
258
  console.log(` Time: ${encodeTime}ms`);
148
259
  console.log(` Saved: ${resolvedOutput}`);
260
+ console.log(' ');
149
261
  }
150
262
  catch (err) {
263
+ console.log(' ');
151
264
  console.error('Error: Failed to encode file. Use --verbose for details.');
152
265
  if (parsed.verbose)
153
266
  console.error('Details:', err.stack || err.message);
@@ -158,6 +271,7 @@ async function decodeCommand(args) {
158
271
  const parsed = parseArgs(args);
159
272
  const [inputPath, outputPath] = parsed._;
160
273
  if (!inputPath) {
274
+ console.log(' ');
161
275
  console.error('Error: Input PNG file required');
162
276
  console.log('Usage: npx rox decode <input> [output] [options]');
163
277
  process.exit(1);
@@ -165,6 +279,7 @@ async function decodeCommand(args) {
165
279
  const resolvedInput = resolve(inputPath);
166
280
  try {
167
281
  const inputBuffer = readFileSync(resolvedInput);
282
+ console.log(' ');
168
283
  console.log(`Reading: ${resolvedInput}`);
169
284
  const options = {};
170
285
  if (parsed.passphrase) {
@@ -173,28 +288,84 @@ async function decodeCommand(args) {
173
288
  if (parsed.debug) {
174
289
  options.debugDir = dirname(resolvedInput);
175
290
  }
291
+ if (parsed.files) {
292
+ options.files = parsed.files;
293
+ }
294
+ console.log(' ');
295
+ console.log(' ');
176
296
  console.log(`Decoding...`);
297
+ console.log(' ');
298
+ const decodeBar = new cliProgress.SingleBar({
299
+ format: ' {bar} {percentage}% | {step} | {elapsed}s',
300
+ }, cliProgress.Presets.shades_classic);
301
+ decodeBar.start(100, 0, {
302
+ step: 'Decoding',
303
+ elapsed: '0',
304
+ });
177
305
  const startDecode = Date.now();
306
+ options.onProgress = (info) => {
307
+ let pct = 50;
308
+ if (info.phase === 'done')
309
+ pct = 100;
310
+ decodeBar.update(pct, {
311
+ step: 'Decoding',
312
+ elapsed: String(Math.floor((Date.now() - startDecode) / 1000)),
313
+ });
314
+ };
178
315
  const result = await decodePngToBinary(inputBuffer, options);
179
316
  const decodeTime = Date.now() - startDecode;
180
- const resolvedOutput = parsed.output || outputPath || result.meta?.name || 'decoded.bin';
181
- writeFileSync(resolvedOutput, result.buf);
182
- const outputSize = (result.buf.length / 1024 / 1024).toFixed(2);
183
- console.log(`\nSuccess!`);
184
- if (result.meta?.name) {
185
- console.log(` Original name: ${result.meta.name}`);
317
+ decodeBar.update(100, {
318
+ step: 'done',
319
+ elapsed: String(Math.floor(decodeTime / 1000)),
320
+ });
321
+ decodeBar.stop();
322
+ if (result.files) {
323
+ const baseDir = parsed.output || outputPath || '.';
324
+ for (const file of result.files) {
325
+ const fullPath = join(baseDir, file.path);
326
+ const dir = dirname(fullPath);
327
+ mkdirSync(dir, { recursive: true });
328
+ writeFileSync(fullPath, file.buf);
329
+ }
330
+ console.log(`\nSuccess!`);
331
+ console.log(`Extracted ${result.files.length} files to ${baseDir === '.' ? 'current directory' : baseDir}`);
332
+ console.log(`Time: ${decodeTime}ms`);
333
+ }
334
+ else if (result.buf) {
335
+ const unpacked = unpackBuffer(result.buf);
336
+ if (unpacked) {
337
+ const baseDir = parsed.output || outputPath || result.meta?.name || '.';
338
+ for (const file of unpacked.files) {
339
+ const fullPath = join(baseDir, file.path);
340
+ const dir = dirname(fullPath);
341
+ mkdirSync(dir, { recursive: true });
342
+ writeFileSync(fullPath, file.buf);
343
+ }
344
+ console.log(`Unpacked ${unpacked.files.length} files to ${baseDir === '.' ? 'current directory' : baseDir}`);
345
+ }
346
+ else {
347
+ const resolvedOutput = parsed.output || outputPath || result.meta?.name || 'decoded.bin';
348
+ writeFileSync(resolvedOutput, result.buf);
349
+ console.log(`\nSuccess!`);
350
+ if (result.meta?.name) {
351
+ console.log(` Original name: ${result.meta.name}`);
352
+ }
353
+ console.log(` Output size: ${(result.buf.length / 1024 / 1024).toFixed(2)} MB`);
354
+ console.log(` Time: ${decodeTime}ms`);
355
+ console.log(` Saved: ${resolvedOutput}`);
356
+ }
186
357
  }
187
- console.log(` Output size: ${outputSize} MB`);
188
- console.log(` Time: ${decodeTime}ms`);
189
- console.log(` Saved: ${resolvedOutput}`);
358
+ console.log(' ');
190
359
  }
191
360
  catch (err) {
192
361
  if (err instanceof PassphraseRequiredError ||
193
362
  (err.message && err.message.includes('passphrase') && !parsed.passphrase)) {
363
+ console.log(' ');
194
364
  console.error('File appears to be encrypted. Provide a passphrase with -p');
195
365
  }
196
366
  else if (err instanceof IncorrectPassphraseError ||
197
367
  (err.message && err.message.includes('Incorrect passphrase'))) {
368
+ console.log(' ');
198
369
  console.error('Incorrect passphrase');
199
370
  }
200
371
  else if (err instanceof DataFormatError ||
@@ -204,13 +375,48 @@ async function decodeCommand(args) {
204
375
  err.message.includes('Pixel payload truncated') ||
205
376
  err.message.includes('Marker START not found') ||
206
377
  err.message.includes('Brotli decompression failed')))) {
378
+ console.log(' ');
207
379
  console.error('Data corrupted or unsupported format. Use --verbose for details.');
208
380
  }
209
381
  else {
382
+ console.log(' ');
210
383
  console.error('Failed to decode file. Use --verbose for details.');
211
384
  }
212
- if (parsed.verbose)
385
+ if (parsed.verbose) {
213
386
  console.error('Details:', err.stack || err.message);
387
+ }
388
+ process.exit(1);
389
+ }
390
+ }
391
+ async function listCommand(args) {
392
+ const parsed = parseArgs(args);
393
+ const [inputPath] = parsed._;
394
+ if (!inputPath) {
395
+ console.log(' ');
396
+ console.error('Error: Input PNG file required');
397
+ console.log('Usage: npx rox list <input>');
398
+ process.exit(1);
399
+ }
400
+ const resolvedInput = resolve(inputPath);
401
+ try {
402
+ const inputBuffer = readFileSync(resolvedInput);
403
+ const fileList = listFilesInPng(inputBuffer);
404
+ if (fileList) {
405
+ console.log(`Files in ${resolvedInput}:`);
406
+ for (const file of fileList) {
407
+ console.log(` ${file}`);
408
+ }
409
+ }
410
+ else {
411
+ console.log('No file list found in the archive.');
412
+ }
413
+ }
414
+ catch (err) {
415
+ console.log(' ');
416
+ console.error('Failed to list files. Use --verbose for details.');
417
+ if (parsed.verbose) {
418
+ console.error('Details:', err.stack || err.message);
419
+ }
214
420
  process.exit(1);
215
421
  }
216
422
  }
@@ -233,6 +439,9 @@ async function main() {
233
439
  case 'decode':
234
440
  await decodeCommand(commandArgs);
235
441
  break;
442
+ case 'list':
443
+ await listCommand(commandArgs);
444
+ break;
236
445
  default:
237
446
  console.error(`Unknown command: ${command}`);
238
447
  console.log('Run "npx rox help" for usage information');
@@ -240,6 +449,7 @@ async function main() {
240
449
  }
241
450
  }
242
451
  main().catch((err) => {
452
+ console.log(' ');
243
453
  console.error('Fatal error:', err);
244
454
  process.exit(1);
245
455
  });
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /// <reference types="node" />
2
2
  /// <reference types="node" />
3
+ import { PackedFile } from './pack.js';
3
4
  export declare class PassphraseRequiredError extends Error {
4
5
  constructor(message?: string);
5
6
  }
@@ -63,6 +64,15 @@ export interface EncodeOptions {
63
64
  * @defaultValue `true`
64
65
  */
65
66
  includeName?: boolean;
67
+ /**
68
+ * Whether to include the file list in the encoded metadata for archives.
69
+ * @defaultValue `false`
70
+ */
71
+ includeFileList?: boolean;
72
+ /**
73
+ * List of file paths for archives (used if includeFileList is true).
74
+ */
75
+ fileList?: string[];
66
76
  /**
67
77
  * Brotli compression quality (0-11).
68
78
  * - Lower values = faster compression, larger output
@@ -70,6 +80,16 @@ export interface EncodeOptions {
70
80
  * @defaultValue `1` (optimized for speed)
71
81
  */
72
82
  brQuality?: number;
83
+ onProgress?: (info: {
84
+ phase: string;
85
+ loaded?: number;
86
+ total?: number;
87
+ }) => void;
88
+ /**
89
+ * Whether to display a progress bar in the console.
90
+ * @defaultValue `false`
91
+ */
92
+ showProgress?: boolean;
73
93
  }
74
94
  /**
75
95
  * Result of decoding a PNG back to binary data.
@@ -79,7 +99,7 @@ export interface DecodeResult {
79
99
  /**
80
100
  * The decoded binary data.
81
101
  */
82
- buf: Buffer;
102
+ buf?: Buffer;
83
103
  /**
84
104
  * Metadata extracted from the encoded image.
85
105
  */
@@ -89,6 +109,10 @@ export interface DecodeResult {
89
109
  */
90
110
  name?: string;
91
111
  };
112
+ /**
113
+ * Extracted files, if selective extraction was requested.
114
+ */
115
+ files?: PackedFile[];
92
116
  }
93
117
  /**
94
118
  * Options for decoding a PNG back to binary data.
@@ -103,6 +127,23 @@ export interface DecodeOptions {
103
127
  * Directory to save debug images (doubled.png, reconstructed.png).
104
128
  */
105
129
  debugDir?: string;
130
+ /**
131
+ * List of files to extract selectively from archives.
132
+ */
133
+ files?: string[];
134
+ /**
135
+ * Progress callback for decoding phases.
136
+ */
137
+ onProgress?: (info: {
138
+ phase: string;
139
+ loaded?: number;
140
+ total?: number;
141
+ }) => void;
142
+ /**
143
+ * Whether to display a progress bar in the console.
144
+ * @defaultValue `false`
145
+ */
146
+ showProgress?: boolean;
106
147
  }
107
148
  export declare function cropAndReconstitute(input: Buffer, debugDir?: string): Promise<Buffer>;
108
149
  /**
@@ -123,3 +164,11 @@ export declare function encodeBinaryToPng(input: Buffer, opts?: EncodeOptions):
123
164
  * @public
124
165
  */
125
166
  export declare function decodePngToBinary(pngBuf: Buffer, opts?: DecodeOptions): Promise<DecodeResult>;
167
+ export { packPaths, unpackBuffer } from './pack.js';
168
+ /**
169
+ * List files in a Rox PNG archive without decoding the full payload.
170
+ * Returns the file list if available, otherwise null.
171
+ * @param pngBuf - PNG data
172
+ * @public
173
+ */
174
+ export declare function listFilesInPng(pngBuf: Buffer): string[] | null;
package/dist/index.js CHANGED
@@ -1,10 +1,12 @@
1
1
  import { compress as zstdCompress, decompress as zstdDecompress, } from '@mongodb-js/zstd';
2
+ import cliProgress from 'cli-progress';
2
3
  import { createCipheriv, createDecipheriv, pbkdf2Sync, randomBytes, } from 'crypto';
3
4
  import { join } from 'path';
4
5
  import encode from 'png-chunks-encode';
5
6
  import extract from 'png-chunks-extract';
6
7
  import sharp from 'sharp';
7
8
  import * as zlib from 'zlib';
9
+ import { unpackBuffer } from './pack.js';
8
10
  const CHUNK_TYPE = 'rXDT';
9
11
  const MAGIC = Buffer.from('ROX1');
10
12
  const PIXEL_MAGIC = Buffer.from('PXL1');
@@ -394,11 +396,60 @@ export async function cropAndReconstitute(input, debugDir) {
394
396
  * @public
395
397
  */
396
398
  export async function encodeBinaryToPng(input, opts = {}) {
399
+ let progressBar = null;
400
+ if (opts.showProgress) {
401
+ progressBar = new cliProgress.SingleBar({
402
+ format: ' {bar} {percentage}% | {step} | {elapsed}s',
403
+ }, cliProgress.Presets.shades_classic);
404
+ progressBar.start(100, 0, { step: 'Starting', elapsed: '0' });
405
+ const startTime = Date.now();
406
+ if (!opts.onProgress) {
407
+ opts.onProgress = (info) => {
408
+ let pct = 0;
409
+ if (info.phase === 'compress_progress' && info.loaded && info.total) {
410
+ pct = (info.loaded / info.total) * 50;
411
+ }
412
+ else if (info.phase === 'compress_done') {
413
+ pct = 50;
414
+ }
415
+ else if (info.phase === 'encrypt_done') {
416
+ pct = 80;
417
+ }
418
+ else if (info.phase === 'png_gen') {
419
+ pct = 90;
420
+ }
421
+ else if (info.phase === 'done') {
422
+ pct = 100;
423
+ }
424
+ progressBar.update(Math.floor(pct), {
425
+ step: info.phase.replace('_', ' '),
426
+ elapsed: String(Math.floor((Date.now() - startTime) / 1000)),
427
+ });
428
+ };
429
+ }
430
+ }
397
431
  let payload = Buffer.concat([MAGIC, input]);
398
432
  const brQuality = typeof opts.brQuality === 'number' ? opts.brQuality : 11;
399
433
  const mode = opts.mode === undefined ? 'screenshot' : opts.mode;
400
434
  const compression = opts.compression || 'zstd';
401
- payload = Buffer.from(await zstdCompress(payload, 22));
435
+ if (opts.onProgress)
436
+ opts.onProgress({ phase: 'compress_start', total: payload.length });
437
+ const compressedChunks = [];
438
+ const chunkSize = 1024 * 1024;
439
+ for (let i = 0; i < payload.length; i += chunkSize) {
440
+ const chunk = payload.slice(i, Math.min(i + chunkSize, payload.length));
441
+ const compressedChunk = Buffer.from(await zstdCompress(chunk, 22));
442
+ compressedChunks.push(compressedChunk);
443
+ if (opts.onProgress)
444
+ opts.onProgress({
445
+ phase: 'compress_progress',
446
+ loaded: i + chunk.length,
447
+ total: payload.length,
448
+ });
449
+ }
450
+ payload = Buffer.concat(compressedChunks);
451
+ if (opts.onProgress)
452
+ opts.onProgress({ phase: 'compress_done', loaded: payload.length });
402
453
  if (opts.passphrase && !opts.encrypt) {
403
454
  opts.encrypt = 'aes';
404
455
  }
@@ -418,6 +469,8 @@ export async function encodeBinaryToPng(input, opts = {}) {
418
469
  }
419
470
  if (opts.passphrase && opts.encrypt && opts.encrypt !== 'auto') {
420
471
  const encChoice = opts.encrypt;
472
+ if (opts.onProgress)
473
+ opts.onProgress({ phase: 'encrypt_start' });
421
474
  if (encChoice === 'aes') {
422
475
  const salt = randomBytes(16);
423
476
  const iv = randomBytes(12);
@@ -431,14 +484,20 @@ export async function encodeBinaryToPng(input, opts = {}) {
431
484
  else if (encChoice === 'xor') {
432
485
  const xored = applyXor(payload, opts.passphrase);
433
486
  payload = Buffer.concat([Buffer.from([ENC_XOR]), xored]);
487
+ if (opts.onProgress)
488
+ opts.onProgress({ phase: 'encrypt_done' });
434
489
  }
435
490
  else if (encChoice === 'none') {
436
491
  payload = Buffer.concat([Buffer.from([ENC_NONE]), payload]);
492
+ if (opts.onProgress)
493
+ opts.onProgress({ phase: 'encrypt_done' });
437
494
  }
438
495
  }
439
496
  else {
440
497
  payload = Buffer.concat([Buffer.from([ENC_NONE]), payload]);
441
498
  }
499
+ if (opts.onProgress)
500
+ opts.onProgress({ phase: 'meta_prep_done', loaded: payload.length });
442
501
  const metaParts = [];
443
502
  const includeName = opts.includeName === undefined ? true : !!opts.includeName;
444
503
  if (includeName && opts.name) {
@@ -532,9 +591,9 @@ export async function encodeBinaryToPng(input, opts = {}) {
532
591
  }
533
592
  }
534
593
  }
535
- return await sharp(raw, {
536
- raw: { width, height, channels: 3 },
537
- })
594
+ if (opts.onProgress)
595
+ opts.onProgress({ phase: 'png_gen' });
596
+ let bufScr = await sharp(raw, { raw: { width, height, channels: 3 } })
538
597
  .png({
539
598
  compressionLevel: 9,
540
599
  palette: false,
@@ -542,6 +601,19 @@ export async function encodeBinaryToPng(input, opts = {}) {
542
601
  adaptiveFiltering: true,
543
602
  })
544
603
  .toBuffer();
604
+ if (opts.includeFileList && opts.fileList) {
605
+ const chunks = extract(bufScr);
606
+ const fileListChunk = {
607
+ name: 'rXFL',
608
+ data: Buffer.from(JSON.stringify(opts.fileList), 'utf8'),
609
+ };
610
+ chunks.splice(-1, 0, fileListChunk);
611
+ bufScr = Buffer.from(encode(chunks));
612
+ }
613
+ if (opts.onProgress)
614
+ opts.onProgress({ phase: 'done', loaded: bufScr.length });
615
+ progressBar?.stop();
616
+ return bufScr;
545
617
  }
546
618
  if (mode === 'pixel') {
547
619
  const nameBuf = opts.name
@@ -600,10 +672,15 @@ export async function encodeBinaryToPng(input, opts = {}) {
600
672
  chunksPixel.push({ name: 'IHDR', data: ihdrData });
601
673
  chunksPixel.push({ name: 'IDAT', data: idatData });
602
674
  chunksPixel.push({ name: 'IEND', data: Buffer.alloc(0) });
675
+ if (opts.onProgress)
676
+ opts.onProgress({ phase: 'png_gen' });
603
677
  const tmp = Buffer.from(encode(chunksPixel));
604
678
  const outPng = tmp.slice(0, 8).toString('hex') === PNG_HEADER_HEX
605
679
  ? tmp
606
680
  : Buffer.concat([PNG_HEADER, tmp]);
681
+ if (opts.onProgress)
682
+ opts.onProgress({ phase: 'done', loaded: outPng.length });
683
+ progressBar?.stop();
607
684
  return outPng;
608
685
  }
609
686
  if (mode === 'compact') {
@@ -633,11 +710,23 @@ export async function encodeBinaryToPng(input, opts = {}) {
633
710
  chunks2.push({ name: 'IHDR', data: ihdrData });
634
711
  chunks2.push({ name: 'IDAT', data: idatData });
635
712
  chunks2.push({ name: CHUNK_TYPE, data: meta });
713
+ if (opts.includeFileList && opts.fileList) {
714
+ chunks2.push({
715
+ name: 'rXFL',
716
+ data: Buffer.from(JSON.stringify(opts.fileList), 'utf8'),
717
+ });
718
+ }
636
719
  chunks2.push({ name: 'IEND', data: Buffer.alloc(0) });
720
+ if (opts.onProgress)
721
+ opts.onProgress({ phase: 'png_gen' });
637
722
  const out = Buffer.from(encode(chunks2));
638
- return out.slice(0, 8).toString('hex') === PNG_HEADER_HEX
723
+ const outBuf = out.slice(0, 8).toString('hex') === PNG_HEADER_HEX
639
724
  ? out
640
725
  : Buffer.concat([PNG_HEADER, out]);
726
+ if (opts.onProgress)
727
+ opts.onProgress({ phase: 'done', loaded: outBuf.length });
728
+ progressBar?.stop();
729
+ return outBuf;
641
730
  }
642
731
  throw new Error(`Unsupported mode: ${mode}`);
643
732
  }
@@ -650,6 +739,34 @@ export async function encodeBinaryToPng(input, opts = {}) {
650
739
  * @public
651
740
  */
652
741
  export async function decodePngToBinary(pngBuf, opts = {}) {
742
+ let progressBar = null;
743
+ if (opts.showProgress) {
744
+ progressBar = new cliProgress.SingleBar({
745
+ format: ' {bar} {percentage}% | {step} | {elapsed}s',
746
+ }, cliProgress.Presets.shades_classic);
747
+ progressBar.start(100, 0, { step: 'Starting', elapsed: '0' });
748
+ const startTime = Date.now();
749
+ if (!opts.onProgress) {
750
+ opts.onProgress = (info) => {
751
+ let pct = 0;
752
+ if (info.phase === 'start') {
753
+ pct = 10;
754
+ }
755
+ else if (info.phase === 'decompress') {
756
+ pct = 50;
757
+ }
758
+ else if (info.phase === 'done') {
759
+ pct = 100;
760
+ }
761
+ progressBar.update(Math.floor(pct), {
762
+ step: info.phase.replace('_', ' '),
763
+ elapsed: String(Math.floor((Date.now() - startTime) / 1000)),
764
+ });
765
+ };
766
+ }
767
+ }
768
+ if (opts.onProgress)
769
+ opts.onProgress({ phase: 'start' });
653
770
  let processedBuf = pngBuf;
654
771
  try {
655
772
  const info = await sharp(pngBuf).metadata();
@@ -666,6 +783,8 @@ export async function decodePngToBinary(pngBuf, opts = {}) {
666
783
  }
667
784
  }
668
785
  catch (e) { }
786
+ if (opts.onProgress)
787
+ opts.onProgress({ phase: 'processed' });
669
788
  if (processedBuf.slice(0, MAGIC.length).equals(MAGIC)) {
670
789
  const d = processedBuf.slice(MAGIC.length);
671
790
  const nameLen = d[0];
@@ -677,6 +796,8 @@ export async function decodePngToBinary(pngBuf, opts = {}) {
677
796
  }
678
797
  const rawPayload = d.slice(idx);
679
798
  let payload = tryDecryptIfNeeded(rawPayload, opts.passphrase);
799
+ if (opts.onProgress)
800
+ opts.onProgress({ phase: 'decompress' });
680
801
  try {
681
802
  payload = await tryZstdDecompress(payload);
682
803
  }
@@ -690,6 +811,9 @@ export async function decodePngToBinary(pngBuf, opts = {}) {
690
811
  throw new Error('Invalid ROX format (ROX direct: missing ROX1 magic after decompression)');
691
812
  }
692
813
  payload = payload.slice(MAGIC.length);
814
+ if (opts.onProgress)
815
+ opts.onProgress({ phase: 'done' });
816
+ progressBar?.stop();
693
817
  return { buf: payload, meta: { name } };
694
818
  }
695
819
  let chunks = [];
@@ -731,6 +855,8 @@ export async function decodePngToBinary(pngBuf, opts = {}) {
731
855
  if (rawPayload.length === 0)
732
856
  throw new DataFormatError('Compact mode payload empty');
733
857
  let payload = tryDecryptIfNeeded(rawPayload, opts.passphrase);
858
+ if (opts.onProgress)
859
+ opts.onProgress({ phase: 'decompress' });
734
860
  try {
735
861
  payload = await tryZstdDecompress(payload);
736
862
  }
@@ -744,6 +870,18 @@ export async function decodePngToBinary(pngBuf, opts = {}) {
744
870
  throw new DataFormatError('Invalid ROX format (compact mode: missing ROX1 magic after decompression)');
745
871
  }
746
872
  payload = payload.slice(MAGIC.length);
873
+ if (opts.files) {
874
+ const unpacked = unpackBuffer(payload, opts.files);
875
+ if (unpacked) {
876
+ if (opts.onProgress)
877
+ opts.onProgress({ phase: 'done' });
878
+ progressBar?.stop();
879
+ return { files: unpacked.files, meta: { name } };
880
+ }
881
+ }
882
+ if (opts.onProgress)
883
+ opts.onProgress({ phase: 'done' });
884
+ progressBar?.stop();
747
885
  return { buf: payload, meta: { name } };
748
886
  }
749
887
  try {
@@ -1087,6 +1225,18 @@ export async function decodePngToBinary(pngBuf, opts = {}) {
1087
1225
  throw new DataFormatError('Invalid ROX format (pixel mode: missing ROX1 magic after decompression)');
1088
1226
  }
1089
1227
  payload = payload.slice(MAGIC.length);
1228
+ if (opts.files) {
1229
+ const unpacked = unpackBuffer(payload, opts.files);
1230
+ if (unpacked) {
1231
+ if (opts.onProgress)
1232
+ opts.onProgress({ phase: 'done' });
1233
+ progressBar?.stop();
1234
+ return { files: unpacked.files, meta: { name } };
1235
+ }
1236
+ }
1237
+ if (opts.onProgress)
1238
+ opts.onProgress({ phase: 'done' });
1239
+ progressBar?.stop();
1090
1240
  return { buf: payload, meta: { name } };
1091
1241
  }
1092
1242
  }
@@ -1111,3 +1261,35 @@ export async function decodePngToBinary(pngBuf, opts = {}) {
1111
1261
  }
1112
1262
  throw new DataFormatError('No valid data found in image');
1113
1263
  }
1264
+ export { packPaths, unpackBuffer } from './pack.js';
1265
+ /**
1266
+ * List files in a Rox PNG archive without decoding the full payload.
1267
+ * Returns the file list if available, otherwise null.
1268
+ * @param pngBuf - PNG data
1269
+ * @public
1270
+ */
1271
+ export function listFilesInPng(pngBuf) {
1272
+ try {
1273
+ const chunks = extract(pngBuf);
1274
+ const fileListChunk = chunks.find((c) => c.name === 'rXFL');
1275
+ if (fileListChunk) {
1276
+ const data = Buffer.isBuffer(fileListChunk.data)
1277
+ ? fileListChunk.data
1278
+ : Buffer.from(fileListChunk.data);
1279
+ const files = JSON.parse(data.toString('utf8'));
1280
+ const dirs = new Set();
1281
+ for (const file of files) {
1282
+ const parts = file.split('/');
1283
+ let path = '';
1284
+ for (let i = 0; i < parts.length - 1; i++) {
1285
+ path += parts[i] + '/';
1286
+ dirs.add(path);
1287
+ }
1288
+ }
1289
+ const all = [...dirs, ...files];
1290
+ return all.sort();
1291
+ }
1292
+ }
1293
+ catch (e) { }
1294
+ return null;
1295
+ }
package/dist/pack.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ /// <reference types="node" />
2
+ /// <reference types="node" />
3
+ export interface PackedFile {
4
+ path: string;
5
+ buf: Buffer;
6
+ }
7
+ export declare function packPaths(paths: string[], baseDir?: string, onProgress?: (readBytes: number, totalBytes: number) => void): {
8
+ buf: Buffer;
9
+ list: string[];
10
+ };
11
+ export declare function unpackBuffer(buf: Buffer, fileList?: string[]): {
12
+ files: {
13
+ path: string;
14
+ buf: Buffer;
15
+ }[];
16
+ } | null;
package/dist/pack.js ADDED
@@ -0,0 +1,85 @@
1
+ import { readFileSync, readdirSync, statSync } from 'fs';
2
+ import { join, relative, resolve, sep } from 'path';
3
+ function collectFiles(paths) {
4
+ const files = [];
5
+ for (const p of paths) {
6
+ const abs = resolve(p);
7
+ const st = statSync(abs);
8
+ if (st.isFile()) {
9
+ files.push(abs);
10
+ }
11
+ else if (st.isDirectory()) {
12
+ const names = readdirSync(abs);
13
+ const childPaths = names.map((n) => join(abs, n));
14
+ files.push(...collectFiles(childPaths));
15
+ }
16
+ }
17
+ return files;
18
+ }
19
+ export function packPaths(paths, baseDir, onProgress) {
20
+ const files = collectFiles(paths);
21
+ const base = baseDir ? resolve(baseDir) : process.cwd();
22
+ const parts = [];
23
+ const list = [];
24
+ let total = 0;
25
+ const sizes = files.map((f) => {
26
+ const st = statSync(f);
27
+ total += st.size;
28
+ return st.size;
29
+ });
30
+ let readSoFar = 0;
31
+ for (let idx = 0; idx < files.length; idx++) {
32
+ const f = files[idx];
33
+ const rel = relative(base, f).split(sep).join('/');
34
+ const content = readFileSync(f);
35
+ const nameBuf = Buffer.from(rel, 'utf8');
36
+ const nameLen = Buffer.alloc(2);
37
+ nameLen.writeUInt16BE(nameBuf.length, 0);
38
+ const sizeBuf = Buffer.alloc(8);
39
+ sizeBuf.writeBigUInt64BE(BigInt(content.length), 0);
40
+ parts.push(nameLen, nameBuf, sizeBuf, content);
41
+ list.push(rel);
42
+ readSoFar += sizes[idx];
43
+ if (onProgress)
44
+ onProgress(readSoFar, total);
45
+ }
46
+ const header = Buffer.alloc(8);
47
+ header.writeUInt32BE(0x524f5850, 0);
48
+ header.writeUInt32BE(files.length, 4);
49
+ parts.unshift(header);
50
+ return { buf: Buffer.concat(parts), list };
51
+ }
52
+ export function unpackBuffer(buf, fileList) {
53
+ if (buf.length < 8)
54
+ return null;
55
+ const header = buf.slice(0, 8);
56
+ if (header.readUInt32BE(0) !== 0x524f5850)
57
+ return null;
58
+ const fileCount = header.readUInt32BE(4);
59
+ let offset = 8;
60
+ const files = [];
61
+ for (let i = 0; i < fileCount; i++) {
62
+ if (offset + 2 > buf.length)
63
+ return null;
64
+ const nameLen = buf.readUInt16BE(offset);
65
+ offset += 2;
66
+ if (offset + nameLen > buf.length)
67
+ return null;
68
+ const name = buf.slice(offset, offset + nameLen).toString('utf8');
69
+ offset += nameLen;
70
+ if (offset + 8 > buf.length)
71
+ return null;
72
+ const size = buf.readBigUInt64BE(offset);
73
+ offset += 8;
74
+ if (offset + Number(size) > buf.length)
75
+ return null;
76
+ const content = buf.slice(offset, offset + Number(size));
77
+ offset += Number(size);
78
+ files.push({ path: name, buf: content });
79
+ }
80
+ if (fileList) {
81
+ const filtered = files.filter((f) => fileList.includes(f.path));
82
+ return { files: filtered };
83
+ }
84
+ return { files };
85
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roxify",
3
- "version": "1.1.4",
3
+ "version": "1.1.6",
4
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",
@@ -15,7 +15,8 @@
15
15
  "scripts": {
16
16
  "build": "tsc",
17
17
  "check-publish": "node ../scripts/check-publish.js roxify",
18
- "cli": "node dist/cli.js"
18
+ "cli": "node dist/cli.js",
19
+ "test": "node test/pack.test.js"
19
20
  },
20
21
  "keywords": [
21
22
  "steganography",
@@ -40,12 +41,14 @@
40
41
  "node": ">=18"
41
42
  },
42
43
  "dependencies": {
43
- "@mongodb-js/zstd": "^7.0.0",
44
+ "@mongodb-js/zstd": "^2.0.1",
44
45
  "png-chunks-encode": "^1.0.0",
45
46
  "png-chunks-extract": "^1.0.0",
46
- "sharp": "^0.34.5"
47
+ "sharp": "^0.34.5",
48
+ "cli-progress": "^3.9.1"
47
49
  },
48
50
  "devDependencies": {
49
- "typescript": "^4.9.5"
51
+ "typescript": "^4.9.5",
52
+ "@types/cli-progress": "^3.9.0"
50
53
  }
51
54
  }