roxify 1.1.3 → 1.1.5

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,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync, writeFileSync } from 'fs';
3
- import { basename, dirname, resolve } from 'path';
4
- import sharp from 'sharp';
5
- import { cropAndReconstitute, DataFormatError, decodePngToBinary, encodeBinaryToPng, IncorrectPassphraseError, PassphraseRequiredError, } from './index.js';
6
- const VERSION = '1.0.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';
7
8
  function showHelp() {
8
9
  console.log(`
9
10
  ROX CLI — Encode/decode binary in PNG
@@ -12,8 +13,9 @@ Usage:
12
13
  npx rox <command> [options]
13
14
 
14
15
  Commands:
15
- encode <input> [output] Encode file to PNG
16
- 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
17
19
 
18
20
  Options:
19
21
  -p, --passphrase <pass> Use passphrase (AES-256-GCM)
@@ -22,6 +24,7 @@ Options:
22
24
  -e, --encrypt <type> auto|aes|xor|none
23
25
  --no-compress Disable compression
24
26
  -o, --output <path> Output file path
27
+ --files <list> Extract only specified files (comma-separated)
25
28
  --view-reconst Export the reconstituted PNG for debugging
26
29
  --debug Export debug images (doubled.png, reconstructed.png)
27
30
  -v, --verbose Show detailed errors
@@ -56,6 +59,10 @@ function parseArgs(args) {
56
59
  parsed.debugDir = args[i + 1];
57
60
  i += 2;
58
61
  }
62
+ else if (key === 'files') {
63
+ parsed.files = args[i + 1].split(',');
64
+ i += 2;
65
+ }
59
66
  else {
60
67
  const value = args[i + 1];
61
68
  parsed[key] = value;
@@ -108,25 +115,92 @@ function parseArgs(args) {
108
115
  }
109
116
  async function encodeCommand(args) {
110
117
  const parsed = parseArgs(args);
111
- const [inputPath, outputPath] = parsed._;
112
- 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) {
113
130
  console.error('Error: Input file required');
114
131
  console.log('Usage: npx rox encode <input> [output] [options]');
115
132
  process.exit(1);
116
133
  }
117
- const resolvedInput = resolve(inputPath);
118
- const resolvedOutput = parsed.output || outputPath || inputPath.replace(/(\.[^.]+)?$/, '.png');
134
+ const resolvedInputs = inputPaths.map((p) => resolve(p));
135
+ const resolvedOutput = parsed.output ||
136
+ outputPath ||
137
+ (inputPaths.length === 1
138
+ ? firstInput.replace(/(\.[^.]+)?$/, '.png')
139
+ : 'archive.png');
140
+ let options = {};
119
141
  try {
120
- console.log(`Reading: ${resolvedInput}`);
121
- const startRead = Date.now();
122
- const inputBuffer = readFileSync(resolvedInput);
123
- const readTime = Date.now() - startRead;
124
- console.log(`Read ${(inputBuffer.length / 1024 / 1024).toFixed(2)} MB in ${readTime}ms`);
125
- const options = {
142
+ let inputBuffer;
143
+ let displayName;
144
+ if (inputPaths.length > 1) {
145
+ console.log(`Packing ${inputPaths.length} inputs...`);
146
+ const bar = new cliProgress.SingleBar({
147
+ format: ' {bar} {percentage}% | {step} | {mbValue}/{mbTotal} MB',
148
+ }, cliProgress.Presets.shades_classic);
149
+ let totalBytes = 0;
150
+ const onProgress = (readBytes, total) => {
151
+ if (totalBytes === 0) {
152
+ totalBytes = total;
153
+ bar.start(totalBytes, 0, {
154
+ step: 'Reading files',
155
+ mbValue: '0.00',
156
+ mbTotal: (totalBytes / 1024 / 1024).toFixed(2),
157
+ });
158
+ }
159
+ bar.update(readBytes, {
160
+ step: 'Reading files',
161
+ mbValue: (readBytes / 1024 / 1024).toFixed(2),
162
+ });
163
+ };
164
+ const packResult = packPaths(inputPaths, undefined, onProgress);
165
+ bar.stop();
166
+ inputBuffer = packResult.buf;
167
+ console.log(`Packed ${packResult.list.length} files -> ${(inputBuffer.length /
168
+ 1024 /
169
+ 1024).toFixed(2)} MB`);
170
+ displayName = parsed.outputName || 'archive';
171
+ options.includeFileList = true;
172
+ options.fileList = packResult.list;
173
+ }
174
+ else {
175
+ const resolvedInput = resolvedInputs[0];
176
+ console.log(' ');
177
+ console.log(`Reading: ${resolvedInput}`);
178
+ console.log(' ');
179
+ const st = statSync(resolvedInput);
180
+ if (st.isDirectory()) {
181
+ console.log(`Packing directory...`);
182
+ const packResult = packPaths([resolvedInput]);
183
+ inputBuffer = packResult.buf;
184
+ console.log(`Packed ${packResult.list.length} files -> ${(inputBuffer.length /
185
+ 1024 /
186
+ 1024).toFixed(2)} MB`);
187
+ displayName = parsed.outputName || basename(resolvedInput);
188
+ options.includeFileList = true;
189
+ options.fileList = packResult.list;
190
+ }
191
+ else {
192
+ const startRead = Date.now();
193
+ inputBuffer = readFileSync(resolvedInput);
194
+ const readTime = Date.now() - startRead;
195
+ console.log('');
196
+ displayName = basename(resolvedInput);
197
+ }
198
+ }
199
+ Object.assign(options, {
126
200
  mode: parsed.mode || 'screenshot',
127
- name: basename(resolvedInput),
201
+ name: displayName,
128
202
  brQuality: parsed.quality !== undefined ? parsed.quality : 11,
129
- };
203
+ });
130
204
  if (parsed.noCompress) {
131
205
  options.compression = 'none';
132
206
  }
@@ -134,10 +208,45 @@ async function encodeCommand(args) {
134
208
  options.passphrase = parsed.passphrase;
135
209
  options.encrypt = parsed.encrypt || 'aes';
136
210
  }
137
- console.log(`Encoding ${basename(resolvedInput)} -> ${resolvedOutput}`);
211
+ console.log(`Encoding ${displayName} -> ${resolvedOutput}\n`);
212
+ const encodeBar = new cliProgress.SingleBar({
213
+ format: ' {bar} {percentage}% | {step} | {elapsed}s',
214
+ }, cliProgress.Presets.shades_classic);
215
+ let totalMB = Math.max(1, Math.round(inputBuffer.length / 1024 / 1024));
216
+ encodeBar.start(100, 0, {
217
+ step: 'Starting',
218
+ elapsed: '0',
219
+ });
138
220
  const startEncode = Date.now();
221
+ options.onProgress = (info) => {
222
+ let pct = 0;
223
+ if (info.phase === 'compress_progress') {
224
+ pct = (info.loaded / info.total) * 50;
225
+ }
226
+ else if (info.phase === 'compress_done') {
227
+ pct = 50;
228
+ }
229
+ else if (info.phase === 'encrypt_done') {
230
+ pct = 80;
231
+ }
232
+ else if (info.phase === 'png_gen') {
233
+ pct = 90;
234
+ }
235
+ else if (info.phase === 'done') {
236
+ pct = 100;
237
+ }
238
+ encodeBar.update(Math.floor(pct), {
239
+ step: info.phase.replace('_', ' '),
240
+ elapsed: String(Math.floor((Date.now() - startEncode) / 1000)),
241
+ });
242
+ };
139
243
  const output = await encodeBinaryToPng(inputBuffer, options);
140
244
  const encodeTime = Date.now() - startEncode;
245
+ encodeBar.update(100, {
246
+ step: 'done',
247
+ elapsed: String(Math.floor(encodeTime / 1000)),
248
+ });
249
+ encodeBar.stop();
141
250
  writeFileSync(resolvedOutput, output);
142
251
  const outputSize = (output.length / 1024 / 1024).toFixed(2);
143
252
  const inputSize = (inputBuffer.length / 1024 / 1024).toFixed(2);
@@ -147,6 +256,7 @@ async function encodeCommand(args) {
147
256
  console.log(` Output: ${outputSize} MB (${ratio}% of original)`);
148
257
  console.log(` Time: ${encodeTime}ms`);
149
258
  console.log(` Saved: ${resolvedOutput}`);
259
+ console.log(' ');
150
260
  }
151
261
  catch (err) {
152
262
  console.error('Error: Failed to encode file. Use --verbose for details.');
@@ -166,8 +276,8 @@ async function decodeCommand(args) {
166
276
  const resolvedInput = resolve(inputPath);
167
277
  try {
168
278
  const inputBuffer = readFileSync(resolvedInput);
279
+ console.log(' ');
169
280
  console.log(`Reading: ${resolvedInput}`);
170
- const info = await sharp(inputBuffer).metadata();
171
281
  const options = {};
172
282
  if (parsed.passphrase) {
173
283
  options.passphrase = parsed.passphrase;
@@ -175,31 +285,74 @@ async function decodeCommand(args) {
175
285
  if (parsed.debug) {
176
286
  options.debugDir = dirname(resolvedInput);
177
287
  }
288
+ if (parsed.files) {
289
+ options.files = parsed.files;
290
+ }
291
+ console.log(' ');
292
+ console.log(' ');
178
293
  console.log(`Decoding...`);
294
+ console.log(' ');
295
+ const decodeBar = new cliProgress.SingleBar({
296
+ format: ' {bar} {percentage}% | {step} | {elapsed}s',
297
+ }, cliProgress.Presets.shades_classic);
298
+ decodeBar.start(100, 0, {
299
+ step: 'Decoding',
300
+ elapsed: '0',
301
+ });
179
302
  const startDecode = Date.now();
180
- if (parsed.verbose)
181
- options.verbose = true;
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);
303
+ options.onProgress = (info) => {
304
+ let pct = 50;
305
+ if (info.phase === 'done')
306
+ pct = 100;
307
+ decodeBar.update(pct, {
308
+ step: 'Decoding',
309
+ elapsed: String(Math.floor((Date.now() - startDecode) / 1000)),
310
+ });
311
+ };
312
+ const result = await decodePngToBinary(inputBuffer, options);
192
313
  const decodeTime = Date.now() - startDecode;
193
- const resolvedOutput = parsed.output || outputPath || result.meta?.name || 'decoded.bin';
194
- writeFileSync(resolvedOutput, result.buf);
195
- const outputSize = (result.buf.length / 1024 / 1024).toFixed(2);
196
- console.log(`\nSuccess!`);
197
- if (result.meta?.name) {
198
- console.log(` Original name: ${result.meta.name}`);
314
+ decodeBar.update(100, {
315
+ step: 'done',
316
+ elapsed: String(Math.floor(decodeTime / 1000)),
317
+ });
318
+ decodeBar.stop();
319
+ if (result.files) {
320
+ const baseDir = parsed.output || outputPath || 'extracted';
321
+ for (const file of result.files) {
322
+ const fullPath = join(baseDir, file.path);
323
+ const dir = dirname(fullPath);
324
+ mkdirSync(dir, { recursive: true });
325
+ writeFileSync(fullPath, file.buf);
326
+ }
327
+ console.log(`\nSuccess!`);
328
+ console.log(`Extracted ${result.files.length} files to ${baseDir}`);
329
+ console.log(`Time: ${decodeTime}ms`);
199
330
  }
200
- console.log(` Output size: ${outputSize} MB`);
201
- console.log(` Time: ${decodeTime}ms`);
202
- console.log(` Saved: ${resolvedOutput}`);
331
+ else if (result.buf) {
332
+ const unpacked = unpackBuffer(result.buf);
333
+ if (unpacked) {
334
+ const baseDir = parsed.output || outputPath || result.meta?.name || 'unpacked';
335
+ for (const file of unpacked.files) {
336
+ const fullPath = join(baseDir, file.path);
337
+ const dir = dirname(fullPath);
338
+ mkdirSync(dir, { recursive: true });
339
+ writeFileSync(fullPath, file.buf);
340
+ }
341
+ console.log(`Unpacked ${unpacked.files.length} files to ${baseDir}`);
342
+ }
343
+ else {
344
+ const resolvedOutput = parsed.output || outputPath || result.meta?.name || 'decoded.bin';
345
+ writeFileSync(resolvedOutput, result.buf);
346
+ console.log(`\nSuccess!`);
347
+ if (result.meta?.name) {
348
+ console.log(` Original name: ${result.meta.name}`);
349
+ }
350
+ console.log(` Output size: ${(result.buf.length / 1024 / 1024).toFixed(2)} MB`);
351
+ console.log(` Time: ${decodeTime}ms`);
352
+ console.log(` Saved: ${resolvedOutput}`);
353
+ }
354
+ }
355
+ console.log(' ');
203
356
  }
204
357
  catch (err) {
205
358
  if (err instanceof PassphraseRequiredError ||
@@ -227,6 +380,35 @@ async function decodeCommand(args) {
227
380
  process.exit(1);
228
381
  }
229
382
  }
383
+ async function listCommand(args) {
384
+ const parsed = parseArgs(args);
385
+ const [inputPath] = parsed._;
386
+ if (!inputPath) {
387
+ console.error('Error: Input PNG file required');
388
+ console.log('Usage: npx rox list <input>');
389
+ process.exit(1);
390
+ }
391
+ const resolvedInput = resolve(inputPath);
392
+ try {
393
+ const inputBuffer = readFileSync(resolvedInput);
394
+ const fileList = listFilesInPng(inputBuffer);
395
+ if (fileList) {
396
+ console.log(`Files in ${resolvedInput}:`);
397
+ for (const file of fileList) {
398
+ console.log(` ${file}`);
399
+ }
400
+ }
401
+ else {
402
+ console.log('No file list found in the archive.');
403
+ }
404
+ }
405
+ catch (err) {
406
+ console.error('Failed to list files. Use --verbose for details.');
407
+ if (parsed.verbose)
408
+ console.error('Details:', err.stack || err.message);
409
+ process.exit(1);
410
+ }
411
+ }
230
412
  async function main() {
231
413
  const args = process.argv.slice(2);
232
414
  if (args.length === 0 || args[0] === 'help' || args[0] === '--help') {
@@ -246,6 +428,9 @@ async function main() {
246
428
  case 'decode':
247
429
  await decodeCommand(commandArgs);
248
430
  break;
431
+ case 'list':
432
+ await listCommand(commandArgs);
433
+ break;
249
434
  default:
250
435
  console.error(`Unknown command: ${command}`);
251
436
  console.log('Run "npx rox help" for usage information');
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,8 +739,54 @@ export async function encodeBinaryToPng(input, opts = {}) {
650
739
  * @public
651
740
  */
652
741
  export async function decodePngToBinary(pngBuf, opts = {}) {
653
- if (pngBuf.slice(0, MAGIC.length).equals(MAGIC)) {
654
- const d = pngBuf.slice(MAGIC.length);
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' });
770
+ let processedBuf = pngBuf;
771
+ try {
772
+ const info = await sharp(pngBuf).metadata();
773
+ if (info.width && info.height) {
774
+ const doubledBuffer = await sharp(pngBuf)
775
+ .resize({
776
+ width: info.width * 2,
777
+ height: info.height * 2,
778
+ kernel: 'nearest',
779
+ })
780
+ .png()
781
+ .toBuffer();
782
+ processedBuf = await cropAndReconstitute(doubledBuffer, opts.debugDir);
783
+ }
784
+ }
785
+ catch (e) { }
786
+ if (opts.onProgress)
787
+ opts.onProgress({ phase: 'processed' });
788
+ if (processedBuf.slice(0, MAGIC.length).equals(MAGIC)) {
789
+ const d = processedBuf.slice(MAGIC.length);
655
790
  const nameLen = d[0];
656
791
  let idx = 1;
657
792
  let name;
@@ -661,6 +796,8 @@ export async function decodePngToBinary(pngBuf, opts = {}) {
661
796
  }
662
797
  const rawPayload = d.slice(idx);
663
798
  let payload = tryDecryptIfNeeded(rawPayload, opts.passphrase);
799
+ if (opts.onProgress)
800
+ opts.onProgress({ phase: 'decompress' });
664
801
  try {
665
802
  payload = await tryZstdDecompress(payload);
666
803
  }
@@ -674,11 +811,14 @@ export async function decodePngToBinary(pngBuf, opts = {}) {
674
811
  throw new Error('Invalid ROX format (ROX direct: missing ROX1 magic after decompression)');
675
812
  }
676
813
  payload = payload.slice(MAGIC.length);
814
+ if (opts.onProgress)
815
+ opts.onProgress({ phase: 'done' });
816
+ progressBar?.stop();
677
817
  return { buf: payload, meta: { name } };
678
818
  }
679
819
  let chunks = [];
680
820
  try {
681
- const chunksRaw = extract(pngBuf);
821
+ const chunksRaw = extract(processedBuf);
682
822
  chunks = chunksRaw.map((c) => ({
683
823
  name: c.name,
684
824
  data: Buffer.isBuffer(c.data)
@@ -715,6 +855,8 @@ export async function decodePngToBinary(pngBuf, opts = {}) {
715
855
  if (rawPayload.length === 0)
716
856
  throw new DataFormatError('Compact mode payload empty');
717
857
  let payload = tryDecryptIfNeeded(rawPayload, opts.passphrase);
858
+ if (opts.onProgress)
859
+ opts.onProgress({ phase: 'decompress' });
718
860
  try {
719
861
  payload = await tryZstdDecompress(payload);
720
862
  }
@@ -728,10 +870,22 @@ export async function decodePngToBinary(pngBuf, opts = {}) {
728
870
  throw new DataFormatError('Invalid ROX format (compact mode: missing ROX1 magic after decompression)');
729
871
  }
730
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();
731
885
  return { buf: payload, meta: { name } };
732
886
  }
733
887
  try {
734
- const { data, info } = await sharp(pngBuf)
888
+ const { data, info } = await sharp(processedBuf)
735
889
  .ensureAlpha()
736
890
  .raw()
737
891
  .toBuffer({ resolveWithObject: true });
@@ -1071,6 +1225,18 @@ export async function decodePngToBinary(pngBuf, opts = {}) {
1071
1225
  throw new DataFormatError('Invalid ROX format (pixel mode: missing ROX1 magic after decompression)');
1072
1226
  }
1073
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();
1074
1240
  return { buf: payload, meta: { name } };
1075
1241
  }
1076
1242
  }
@@ -1095,3 +1261,24 @@ export async function decodePngToBinary(pngBuf, opts = {}) {
1095
1261
  }
1096
1262
  throw new DataFormatError('No valid data found in image');
1097
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
+ return JSON.parse(data.toString('utf8'));
1280
+ }
1281
+ }
1282
+ catch (e) { }
1283
+ return null;
1284
+ }
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.3",
3
+ "version": "1.1.5",
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
  }