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 +83 -16
- package/dist/cli.js +227 -42
- package/dist/index.d.ts +50 -1
- package/dist/index.js +196 -9
- package/dist/pack.d.ts +16 -0
- package/dist/pack.js +85 -0
- package/package.json +8 -5
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
|
|
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<
|
|
114
|
+
- `decodePngToBinary(pngBuf: Buffer, opts?: DecodeOptions): Promise<DecodeResult>`
|
|
115
|
+
- `listFilesInPng(pngBuf: Buffer): string[] | null`
|
|
73
116
|
|
|
74
|
-
**
|
|
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
|
-
|
|
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
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
6
|
-
|
|
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
|
|
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
|
|
112
|
-
|
|
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
|
|
118
|
-
const resolvedOutput = parsed.output ||
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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:
|
|
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 ${
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
536
|
-
|
|
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
|
-
|
|
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
|
-
|
|
654
|
-
|
|
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(
|
|
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(
|
|
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
|
+
"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": "^
|
|
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
|
}
|