roxify 1.1.1 → 1.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -4
- package/dist/cli.js +22 -19
- package/dist/index.d.ts +19 -8
- package/dist/index.js +252 -210
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -6,7 +6,15 @@ Encode binary data into PNG images and decode them back. Supports CLI and progra
|
|
|
6
6
|
|
|
7
7
|
Roxify is a compact, color-based alternative to QR codes, designed specifically for digital-only use (not for printing). It encodes data using color channels (rather than monochrome patterns) for higher density, and is optimized for decoding from approximate screenshots — including nearest-neighbour resize/stretch and solid or gradient backgrounds. It is not intended for printed media and is not resilient to lossy compression or heavy image filtering.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Roxify creates PNGs that are often more space-efficient than ZIP or 7z archives for similar payloads without loss. Roxify provides superior compression ratios, making it ideal for embedding images, GIFs, audio, video, code, and other files without any quality loss — the original file is perfectly recovered upon decoding.
|
|
10
|
+
|
|
11
|
+
Key benefits:
|
|
12
|
+
|
|
13
|
+
- **Superior Compression**: Roxify outperforms traditional ZIP and 7z (LZMA) in speed and ratio, enabling smaller PNG outputs.
|
|
14
|
+
- **Lossless Embedding**: Compress and embed any file type (images, videos, code) with full fidelity restoration.
|
|
15
|
+
- **Code Efficiency**: Hyper-efficient for compressing source code, reducing file sizes dramatically.
|
|
16
|
+
- **Obfuscation & Security**: Obfuscate code or lock files with AES-256-GCM encryption, more compact than password-protected ZIPs.
|
|
17
|
+
- **Visual Data Indicator**: PNG size visually represents embedded data size, providing an intuitive overview.
|
|
10
18
|
|
|
11
19
|
## Installation
|
|
12
20
|
|
|
@@ -36,7 +44,7 @@ If no output name is provided:
|
|
|
36
44
|
|
|
37
45
|
- `-p, --passphrase <pass>` — Encrypt with AES-256-GCM
|
|
38
46
|
- `-m, --mode <mode>` — Encoding mode: `screenshot` (default), `pixel`, `compact`, `chunk`
|
|
39
|
-
- `-q, --quality <0-
|
|
47
|
+
- `-q, --quality <0-22>` — Roxify compression level (default: 22)
|
|
40
48
|
- `--no-compress` — Disable compression
|
|
41
49
|
- `-v, --verbose` — Show detailed errors
|
|
42
50
|
|
|
@@ -68,8 +76,8 @@ console.log(meta?.name);
|
|
|
68
76
|
- `mode` — `'screenshot'` | `'pixel'` | `'compact'` | `'chunk'` (default: `'screenshot'`)
|
|
69
77
|
- `name` — Original filename (embedded as metadata)
|
|
70
78
|
- `passphrase` — Encryption passphrase (uses AES-256-GCM)
|
|
71
|
-
- `compression` — `'
|
|
72
|
-
- `brQuality` —
|
|
79
|
+
- `compression` — `'Roxify'` | `'none'` (default: `'Roxify'`)
|
|
80
|
+
- `brQuality` — Roxify compression level 0-22 (default: 22)
|
|
73
81
|
|
|
74
82
|
## Example: Express Endpoint
|
|
75
83
|
|
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync, writeFileSync } from 'fs';
|
|
3
|
-
import { basename, resolve } from 'path';
|
|
3
|
+
import { basename, dirname, resolve } from 'path';
|
|
4
|
+
import sharp from 'sharp';
|
|
4
5
|
import { cropAndReconstitute, DataFormatError, decodePngToBinary, encodeBinaryToPng, IncorrectPassphraseError, PassphraseRequiredError, } from './index.js';
|
|
5
6
|
const VERSION = '1.0.4';
|
|
6
7
|
function showHelp() {
|
|
@@ -22,6 +23,7 @@ Options:
|
|
|
22
23
|
--no-compress Disable compression
|
|
23
24
|
-o, --output <path> Output file path
|
|
24
25
|
--view-reconst Export the reconstituted PNG for debugging
|
|
26
|
+
--debug Export debug images (doubled.png, reconstructed.png)
|
|
25
27
|
-v, --verbose Show detailed errors
|
|
26
28
|
|
|
27
29
|
Run "npx rox help" for this message.
|
|
@@ -46,6 +48,10 @@ function parseArgs(args) {
|
|
|
46
48
|
parsed.viewReconst = true;
|
|
47
49
|
i++;
|
|
48
50
|
}
|
|
51
|
+
else if (key === 'debug') {
|
|
52
|
+
parsed.debug = true;
|
|
53
|
+
i++;
|
|
54
|
+
}
|
|
49
55
|
else if (key === 'debug-dir') {
|
|
50
56
|
parsed.debugDir = args[i + 1];
|
|
51
57
|
i += 2;
|
|
@@ -159,33 +165,30 @@ async function decodeCommand(args) {
|
|
|
159
165
|
}
|
|
160
166
|
const resolvedInput = resolve(inputPath);
|
|
161
167
|
try {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const reconst = await cropAndReconstitute(inputBuffer);
|
|
166
|
-
inputBuffer = reconst;
|
|
167
|
-
resolvedInputPath = resolvedInput.replace(/(\.\w+)?$/, '_reconst.png');
|
|
168
|
-
if (parsed.viewReconst) {
|
|
169
|
-
writeFileSync(resolvedInputPath, reconst);
|
|
170
|
-
console.log(`Reconst PNG: ${resolvedInputPath}`);
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
catch (e) {
|
|
174
|
-
console.log('Could not generate reconst PNG:', e.message);
|
|
175
|
-
}
|
|
176
|
-
console.log(`Reading: ${resolvedInputPath.replace('_reconst.png', '.png')}`);
|
|
168
|
+
const inputBuffer = readFileSync(resolvedInput);
|
|
169
|
+
console.log(`Reading: ${resolvedInput}`);
|
|
170
|
+
const info = await sharp(inputBuffer).metadata();
|
|
177
171
|
const options = {};
|
|
178
172
|
if (parsed.passphrase) {
|
|
179
173
|
options.passphrase = parsed.passphrase;
|
|
180
174
|
}
|
|
181
|
-
if (parsed.
|
|
182
|
-
options.debugDir =
|
|
175
|
+
if (parsed.debug) {
|
|
176
|
+
options.debugDir = dirname(resolvedInput);
|
|
183
177
|
}
|
|
184
178
|
console.log(`Decoding...`);
|
|
185
179
|
const startDecode = Date.now();
|
|
186
180
|
if (parsed.verbose)
|
|
187
181
|
options.verbose = true;
|
|
188
|
-
const
|
|
182
|
+
const doubledBuffer = await sharp(inputBuffer)
|
|
183
|
+
.resize({
|
|
184
|
+
width: info.width * 2,
|
|
185
|
+
height: info.height * 2,
|
|
186
|
+
kernel: 'nearest',
|
|
187
|
+
})
|
|
188
|
+
.png()
|
|
189
|
+
.toBuffer();
|
|
190
|
+
const reconstructedBuffer = await cropAndReconstitute(doubledBuffer, options.debugDir);
|
|
191
|
+
const result = await decodePngToBinary(reconstructedBuffer, options);
|
|
189
192
|
const decodeTime = Date.now() - startDecode;
|
|
190
193
|
const resolvedOutput = parsed.output || outputPath || result.meta?.name || 'decoded.bin';
|
|
191
194
|
writeFileSync(resolvedOutput, result.buf);
|
package/dist/index.d.ts
CHANGED
|
@@ -16,11 +16,10 @@ export declare class DataFormatError extends Error {
|
|
|
16
16
|
export interface EncodeOptions {
|
|
17
17
|
/**
|
|
18
18
|
* Compression algorithm to use.
|
|
19
|
-
* - `'
|
|
20
|
-
*
|
|
21
|
-
* @defaultValue `'br'` for most modes
|
|
19
|
+
* - `'zstd'`: Zstandard compression (maximum compression for smallest files)
|
|
20
|
+
* @defaultValue `'zstd'`
|
|
22
21
|
*/
|
|
23
|
-
compression?: '
|
|
22
|
+
compression?: 'zstd';
|
|
24
23
|
/**
|
|
25
24
|
* Passphrase for encryption. If provided without `encrypt` option, defaults to AES-256-GCM.
|
|
26
25
|
*/
|
|
@@ -91,7 +90,21 @@ export interface DecodeResult {
|
|
|
91
90
|
name?: string;
|
|
92
91
|
};
|
|
93
92
|
}
|
|
94
|
-
|
|
93
|
+
/**
|
|
94
|
+
* Options for decoding a PNG back to binary data.
|
|
95
|
+
* @public
|
|
96
|
+
*/
|
|
97
|
+
export interface DecodeOptions {
|
|
98
|
+
/**
|
|
99
|
+
* Passphrase for encrypted inputs.
|
|
100
|
+
*/
|
|
101
|
+
passphrase?: string;
|
|
102
|
+
/**
|
|
103
|
+
* Directory to save debug images (doubled.png, reconstructed.png).
|
|
104
|
+
*/
|
|
105
|
+
debugDir?: string;
|
|
106
|
+
}
|
|
107
|
+
export declare function cropAndReconstitute(input: Buffer, debugDir?: string): Promise<Buffer>;
|
|
95
108
|
/**
|
|
96
109
|
* Encode a Buffer into a PNG wrapper. Supports optional compression and
|
|
97
110
|
* encryption. Defaults are chosen for a good balance between speed and size.
|
|
@@ -109,6 +122,4 @@ export declare function encodeBinaryToPng(input: Buffer, opts?: EncodeOptions):
|
|
|
109
122
|
* @param opts - Options (passphrase for encrypted inputs)
|
|
110
123
|
* @public
|
|
111
124
|
*/
|
|
112
|
-
export declare function decodePngToBinary(pngBuf: Buffer, opts?:
|
|
113
|
-
passphrase?: string;
|
|
114
|
-
}): Promise<DecodeResult>;
|
|
125
|
+
export declare function decodePngToBinary(pngBuf: Buffer, opts?: DecodeOptions): Promise<DecodeResult>;
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { compress as zstdCompress, decompress as zstdDecompress, } from '@mongodb-js/zstd';
|
|
1
2
|
import { createCipheriv, createDecipheriv, pbkdf2Sync, randomBytes, } from 'crypto';
|
|
3
|
+
import { join } from 'path';
|
|
2
4
|
import encode from 'png-chunks-encode';
|
|
3
5
|
import extract from 'png-chunks-extract';
|
|
4
6
|
import sharp from 'sharp';
|
|
@@ -38,6 +40,9 @@ const MARKER_COLORS = [
|
|
|
38
40
|
];
|
|
39
41
|
const MARKER_START = MARKER_COLORS;
|
|
40
42
|
const MARKER_END = [...MARKER_COLORS].reverse();
|
|
43
|
+
const COMPRESSION_MARKERS = {
|
|
44
|
+
zstd: [{ r: 0, g: 255, b: 0 }],
|
|
45
|
+
};
|
|
41
46
|
function colorsToBytes(colors) {
|
|
42
47
|
const buf = Buffer.alloc(colors.length * 3);
|
|
43
48
|
for (let i = 0; i < colors.length; i++) {
|
|
@@ -58,6 +63,15 @@ function applyXor(buf, passphrase) {
|
|
|
58
63
|
function tryBrotliDecompress(payload) {
|
|
59
64
|
return Buffer.from(zlib.brotliDecompressSync(payload));
|
|
60
65
|
}
|
|
66
|
+
async function tryZstdDecompress(payload) {
|
|
67
|
+
try {
|
|
68
|
+
const result = await zstdDecompress(payload);
|
|
69
|
+
return Buffer.from(result);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return payload;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
61
75
|
function tryDecryptIfNeeded(buf, passphrase) {
|
|
62
76
|
if (!buf || buf.length === 0)
|
|
63
77
|
return buf;
|
|
@@ -107,7 +121,7 @@ async function loadRaw(imgInput) {
|
|
|
107
121
|
.toBuffer({ resolveWithObject: true });
|
|
108
122
|
return { data, info };
|
|
109
123
|
}
|
|
110
|
-
export async function cropAndReconstitute(input) {
|
|
124
|
+
export async function cropAndReconstitute(input, debugDir) {
|
|
111
125
|
async function loadRaw(imgInput) {
|
|
112
126
|
const { data, info } = await sharp(imgInput)
|
|
113
127
|
.ensureAlpha()
|
|
@@ -122,195 +136,131 @@ export async function cropAndReconstitute(input) {
|
|
|
122
136
|
return a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
|
|
123
137
|
}
|
|
124
138
|
const { data, info } = await loadRaw(input);
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
if (p[0] !== 255 || p[1] !== 0 || p[2] !== 0)
|
|
136
|
-
continue;
|
|
137
|
-
let nx = x + 1;
|
|
138
|
-
while (nx < w && eqRGB(at(nx, y), p))
|
|
139
|
-
nx++;
|
|
140
|
-
if (nx >= w)
|
|
141
|
-
continue;
|
|
142
|
-
const a = at(nx, y);
|
|
143
|
-
let nx2 = nx + 1;
|
|
144
|
-
while (nx2 < w && eqRGB(at(nx2, y), a))
|
|
145
|
-
nx2++;
|
|
146
|
-
if (nx2 >= w)
|
|
147
|
-
continue;
|
|
148
|
-
const b = at(nx2, y);
|
|
149
|
-
const isRgb = a[0] === 0 &&
|
|
150
|
-
a[1] === 255 &&
|
|
151
|
-
a[2] === 0 &&
|
|
152
|
-
b[0] === 0 &&
|
|
153
|
-
b[1] === 0 &&
|
|
154
|
-
b[2] === 255;
|
|
155
|
-
if (isRgb) {
|
|
156
|
-
startPoint = { x, y, type: 'rgb' };
|
|
157
|
-
break;
|
|
158
|
-
}
|
|
159
|
-
}
|
|
139
|
+
const doubledBuffer = await sharp(input)
|
|
140
|
+
.resize({
|
|
141
|
+
width: info.width * 2,
|
|
142
|
+
height: info.height * 2,
|
|
143
|
+
kernel: 'nearest',
|
|
144
|
+
})
|
|
145
|
+
.png()
|
|
146
|
+
.toBuffer();
|
|
147
|
+
if (debugDir) {
|
|
148
|
+
await sharp(doubledBuffer).toFile(join(debugDir, 'doubled.png'));
|
|
160
149
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
150
|
+
const { data: doubledData, info: doubledInfo } = await loadRaw(doubledBuffer);
|
|
151
|
+
const w = doubledInfo.width, h = doubledInfo.height;
|
|
152
|
+
const at = (x, y) => {
|
|
153
|
+
const i = idxFor(x, y, w);
|
|
154
|
+
return [
|
|
155
|
+
doubledData[i],
|
|
156
|
+
doubledData[i + 1],
|
|
157
|
+
doubledData[i + 2],
|
|
158
|
+
doubledData[i + 3],
|
|
159
|
+
];
|
|
160
|
+
};
|
|
161
|
+
const findPattern = (startX, startY, dirX, dirY, pattern) => {
|
|
162
|
+
for (let y = startY; y >= 0 && y < h; y += dirY) {
|
|
163
|
+
for (let x = startX; x >= 0 && x < w; x += dirX) {
|
|
164
|
+
const p = at(x, y);
|
|
165
|
+
if (p[0] !== 255 || p[1] !== 0 || p[2] !== 0)
|
|
166
|
+
continue;
|
|
167
|
+
let nx = x + dirX;
|
|
168
|
+
while (nx >= 0 && nx < w && eqRGB(at(nx, y), p))
|
|
169
|
+
nx += dirX;
|
|
170
|
+
if (nx < 0 || nx >= w)
|
|
171
|
+
continue;
|
|
172
|
+
const a = at(nx, y);
|
|
173
|
+
let nx2 = nx + dirX;
|
|
174
|
+
while (nx2 >= 0 && nx2 < w && eqRGB(at(nx2, y), a))
|
|
175
|
+
nx2 += dirX;
|
|
176
|
+
if (nx2 < 0 || nx2 >= w)
|
|
177
|
+
continue;
|
|
178
|
+
const b = at(nx2, y);
|
|
179
|
+
if (a[0] === pattern[0][0] &&
|
|
180
|
+
a[1] === pattern[0][1] &&
|
|
181
|
+
a[2] === pattern[0][2] &&
|
|
182
|
+
b[0] === pattern[1][0] &&
|
|
183
|
+
b[1] === pattern[1][1] &&
|
|
184
|
+
b[2] === pattern[1][2]) {
|
|
185
|
+
return { x, y };
|
|
186
|
+
}
|
|
188
187
|
}
|
|
189
188
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
189
|
+
return null;
|
|
190
|
+
};
|
|
191
|
+
const startPoint = findPattern(0, 0, 1, 1, [
|
|
192
|
+
[0, 255, 0],
|
|
193
|
+
[0, 0, 255],
|
|
194
|
+
]);
|
|
195
|
+
const endPoint = findPattern(w - 1, h - 1, -1, -1, [
|
|
196
|
+
[0, 255, 0],
|
|
197
|
+
[0, 0, 255],
|
|
198
|
+
]);
|
|
199
|
+
if (!startPoint || !endPoint)
|
|
200
|
+
throw new Error('Patterns not found');
|
|
201
|
+
const sx1 = Math.min(startPoint.x, endPoint.x), sy1 = Math.min(startPoint.y, endPoint.y);
|
|
202
|
+
const sx2 = Math.max(startPoint.x, endPoint.x), sy2 = Math.max(startPoint.y, endPoint.y);
|
|
203
|
+
const cropW = sx2 - sx1 + 1, cropH = sy2 - sy1 + 1;
|
|
201
204
|
if (cropW <= 0 || cropH <= 0)
|
|
202
205
|
throw new Error('Invalid crop dimensions');
|
|
203
|
-
const cropped = await sharp(
|
|
206
|
+
const cropped = await sharp(doubledBuffer)
|
|
204
207
|
.extract({ left: sx1, top: sy1, width: cropW, height: cropH })
|
|
205
208
|
.png()
|
|
206
209
|
.toBuffer();
|
|
207
|
-
const { data: cdata, info: cinfo } = await
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
.toBuffer({ resolveWithObject: true });
|
|
211
|
-
const cw = cinfo.width;
|
|
212
|
-
const ch = cinfo.height;
|
|
213
|
-
function cat(x, y) {
|
|
214
|
-
const i = idxFor(x, y, cw);
|
|
215
|
-
return [cdata[i], cdata[i + 1], cdata[i + 2], cdata[i + 3]];
|
|
216
|
-
}
|
|
217
|
-
function eq(a, b) {
|
|
218
|
-
return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3];
|
|
219
|
-
}
|
|
220
|
-
function lineEq(l1, l2) {
|
|
221
|
-
if (l1.length !== l2.length)
|
|
222
|
-
return false;
|
|
223
|
-
for (let i = 0; i < l1.length; i++)
|
|
224
|
-
if (!eq(l1[i], l2[i]))
|
|
225
|
-
return false;
|
|
226
|
-
return true;
|
|
227
|
-
}
|
|
228
|
-
const newWidth = cw;
|
|
229
|
-
const newHeight = ch + 1;
|
|
210
|
+
const { data: cdata, info: cinfo } = await loadRaw(cropped);
|
|
211
|
+
const cw = cinfo.width, ch = cinfo.height;
|
|
212
|
+
const newWidth = cw, newHeight = ch + 1;
|
|
230
213
|
const out = Buffer.alloc(newWidth * newHeight * 4, 0);
|
|
231
214
|
for (let i = 0; i < out.length; i += 4)
|
|
232
215
|
out[i + 3] = 255;
|
|
233
216
|
for (let y = 0; y < ch; y++) {
|
|
234
217
|
for (let x = 0; x < cw; x++) {
|
|
235
|
-
const srcI = (
|
|
236
|
-
const dstI = (
|
|
218
|
+
const srcI = (y * cw + x) * 4;
|
|
219
|
+
const dstI = (y * newWidth + x) * 4;
|
|
237
220
|
out[dstI] = cdata[srcI];
|
|
238
221
|
out[dstI + 1] = cdata[srcI + 1];
|
|
239
222
|
out[dstI + 2] = cdata[srcI + 2];
|
|
240
223
|
out[dstI + 3] = cdata[srcI + 3];
|
|
241
224
|
}
|
|
242
225
|
}
|
|
243
|
-
if (cw >= 3) {
|
|
244
|
-
const targetY = ch - 1;
|
|
245
|
-
for (let x = cw - 3; x < cw; x++) {
|
|
246
|
-
const i = ((targetY * newWidth + x) * 4) | 0;
|
|
247
|
-
out[i] = 0;
|
|
248
|
-
out[i + 1] = 0;
|
|
249
|
-
out[i + 2] = 0;
|
|
250
|
-
out[i + 3] = 255;
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
else {
|
|
254
|
-
const targetY = ch - 1;
|
|
255
|
-
for (let x = 0; x < cw; x++) {
|
|
256
|
-
const i = ((targetY * newWidth + x) * 4) | 0;
|
|
257
|
-
out[i] = 0;
|
|
258
|
-
out[i + 1] = 0;
|
|
259
|
-
out[i + 2] = 0;
|
|
260
|
-
out[i + 3] = 255;
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
const lastY = ch;
|
|
264
226
|
for (let x = 0; x < newWidth; x++) {
|
|
265
|
-
const i = ((
|
|
266
|
-
out[i] = 0;
|
|
267
|
-
out[i + 1] = 0;
|
|
268
|
-
out[i + 2] = 0;
|
|
227
|
+
const i = ((ch - 1) * newWidth + x) * 4;
|
|
228
|
+
out[i] = out[i + 1] = out[i + 2] = 0;
|
|
269
229
|
out[i + 3] = 255;
|
|
230
|
+
const j = (ch * newWidth + x) * 4;
|
|
231
|
+
out[j] = out[j + 1] = out[j + 2] = 0;
|
|
232
|
+
out[j + 3] = 255;
|
|
270
233
|
}
|
|
271
234
|
if (newWidth >= 3) {
|
|
272
235
|
const bgrStart = newWidth - 3;
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
out[i + 1] = 0;
|
|
286
|
-
out[i + 2] = 0;
|
|
287
|
-
out[i + 3] = 255;
|
|
236
|
+
const bgr = [
|
|
237
|
+
[0, 0, 255],
|
|
238
|
+
[0, 255, 0],
|
|
239
|
+
[255, 0, 0],
|
|
240
|
+
];
|
|
241
|
+
for (let k = 0; k < 3; k++) {
|
|
242
|
+
const i = (ch * newWidth + bgrStart + k) * 4;
|
|
243
|
+
out[i] = bgr[k][0];
|
|
244
|
+
out[i + 1] = bgr[k][1];
|
|
245
|
+
out[i + 2] = bgr[k][2];
|
|
246
|
+
out[i + 3] = 255;
|
|
247
|
+
}
|
|
288
248
|
}
|
|
289
|
-
|
|
290
|
-
const i = (
|
|
249
|
+
const getPixel = (x, y) => {
|
|
250
|
+
const i = (y * newWidth + x) * 4;
|
|
291
251
|
return [out[i], out[i + 1], out[i + 2], out[i + 3]];
|
|
292
|
-
}
|
|
252
|
+
};
|
|
293
253
|
const compressedLines = [];
|
|
294
254
|
for (let y = 0; y < newHeight; y++) {
|
|
295
255
|
const line = [];
|
|
296
|
-
let x = 0;
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
}
|
|
303
|
-
line.push(current);
|
|
304
|
-
let nx = x + 1;
|
|
305
|
-
while (nx < newWidth && eq(getPixel(nx, y), current))
|
|
306
|
-
nx++;
|
|
307
|
-
x = nx;
|
|
308
|
-
}
|
|
309
|
-
if (line.length === 0)
|
|
310
|
-
continue;
|
|
311
|
-
if (compressedLines.length === 0 ||
|
|
312
|
-
!lineEq(compressedLines[compressedLines.length - 1], line))
|
|
256
|
+
for (let x = 0; x < newWidth; x++)
|
|
257
|
+
line.push(getPixel(x, y));
|
|
258
|
+
const isAllBlack = line.every((p) => p[0] === 0 && p[1] === 0 && p[2] === 0 && p[3] === 255);
|
|
259
|
+
if (!isAllBlack &&
|
|
260
|
+
(compressedLines.length === 0 ||
|
|
261
|
+
!line.every((p, i) => p.every((v, j) => v === compressedLines[compressedLines.length - 1][i][j])))) {
|
|
313
262
|
compressedLines.push(line);
|
|
263
|
+
}
|
|
314
264
|
}
|
|
315
265
|
if (compressedLines.length === 0) {
|
|
316
266
|
return sharp({
|
|
@@ -324,21 +274,109 @@ export async function cropAndReconstitute(input) {
|
|
|
324
274
|
.png()
|
|
325
275
|
.toBuffer();
|
|
326
276
|
}
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
const finalOut = Buffer.alloc(finalWidth * finalHeight * 4, 0);
|
|
277
|
+
let finalWidth = newWidth, finalHeight = compressedLines.length;
|
|
278
|
+
let finalOut = Buffer.alloc(finalWidth * finalHeight * 4, 0);
|
|
330
279
|
for (let i = 0; i < finalOut.length; i += 4)
|
|
331
280
|
finalOut[i + 3] = 255;
|
|
332
|
-
for (let y = 0; y <
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
finalOut[i] =
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
281
|
+
for (let y = 0; y < finalHeight; y++) {
|
|
282
|
+
for (let x = 0; x < finalWidth; x++) {
|
|
283
|
+
const i = (y * finalWidth + x) * 4;
|
|
284
|
+
finalOut[i] = compressedLines[y][x][0];
|
|
285
|
+
finalOut[i + 1] = compressedLines[y][x][1];
|
|
286
|
+
finalOut[i + 2] = compressedLines[y][x][2];
|
|
287
|
+
finalOut[i + 3] = compressedLines[y][x][3] || 255;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (finalHeight >= 1 && finalWidth >= 3) {
|
|
291
|
+
const lastY = finalHeight - 1;
|
|
292
|
+
for (let k = 0; k < 3; k++) {
|
|
293
|
+
const i = (lastY * finalWidth + finalWidth - 3 + k) * 4;
|
|
294
|
+
finalOut[i] = finalOut[i + 1] = finalOut[i + 2] = 0;
|
|
295
|
+
finalOut[i + 3] = 255;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (finalWidth >= 2) {
|
|
299
|
+
const kept = [];
|
|
300
|
+
for (let x = 0; x < finalWidth; x++) {
|
|
301
|
+
if (kept.length === 0) {
|
|
302
|
+
kept.push(x);
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
const prevX = kept[kept.length - 1];
|
|
306
|
+
let same = true;
|
|
307
|
+
for (let y = 0; y < finalHeight; y++) {
|
|
308
|
+
const ia = (y * finalWidth + prevX) * 4, ib = (y * finalWidth + x) * 4;
|
|
309
|
+
if (finalOut[ia] !== finalOut[ib] ||
|
|
310
|
+
finalOut[ia + 1] !== finalOut[ib + 1] ||
|
|
311
|
+
finalOut[ia + 2] !== finalOut[ib + 2] ||
|
|
312
|
+
finalOut[ia + 3] !== finalOut[ib + 3]) {
|
|
313
|
+
same = false;
|
|
314
|
+
break;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (!same)
|
|
318
|
+
kept.push(x);
|
|
319
|
+
}
|
|
320
|
+
if (kept.length !== finalWidth) {
|
|
321
|
+
const newFinalWidth = kept.length;
|
|
322
|
+
const newOut = Buffer.alloc(newFinalWidth * finalHeight * 4, 0);
|
|
323
|
+
for (let i = 0; i < newOut.length; i += 4)
|
|
324
|
+
newOut[i + 3] = 255;
|
|
325
|
+
for (let nx = 0; nx < kept.length; nx++) {
|
|
326
|
+
const sx = kept[nx];
|
|
327
|
+
for (let y = 0; y < finalHeight; y++) {
|
|
328
|
+
const srcI = (y * finalWidth + sx) * 4, dstI = (y * newFinalWidth + nx) * 4;
|
|
329
|
+
newOut[dstI] = finalOut[srcI];
|
|
330
|
+
newOut[dstI + 1] = finalOut[srcI + 1];
|
|
331
|
+
newOut[dstI + 2] = finalOut[srcI + 2];
|
|
332
|
+
newOut[dstI + 3] = finalOut[srcI + 3];
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
finalOut = newOut;
|
|
336
|
+
finalWidth = newFinalWidth;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
if (finalHeight >= 2 && finalWidth >= 3) {
|
|
340
|
+
const secondLastY = finalHeight - 2;
|
|
341
|
+
const bgrSeq = [
|
|
342
|
+
[0, 0, 255],
|
|
343
|
+
[0, 255, 0],
|
|
344
|
+
[255, 0, 0],
|
|
345
|
+
];
|
|
346
|
+
let hasBGR = true;
|
|
347
|
+
for (let k = 0; k < 3; k++) {
|
|
348
|
+
const i = (secondLastY * finalWidth + finalWidth - 3 + k) * 4;
|
|
349
|
+
if (finalOut[i] !== bgrSeq[k][0] ||
|
|
350
|
+
finalOut[i + 1] !== bgrSeq[k][1] ||
|
|
351
|
+
finalOut[i + 2] !== bgrSeq[k][2]) {
|
|
352
|
+
hasBGR = false;
|
|
353
|
+
break;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (hasBGR) {
|
|
357
|
+
for (let k = 0; k < 3; k++) {
|
|
358
|
+
const i = (secondLastY * finalWidth + finalWidth - 3 + k) * 4;
|
|
359
|
+
finalOut[i] = finalOut[i + 1] = finalOut[i + 2] = 0;
|
|
360
|
+
finalOut[i + 3] = 255;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (finalHeight >= 1 && finalWidth >= 1) {
|
|
365
|
+
const lastYFinal = finalHeight - 1;
|
|
366
|
+
const bgrSeq = [
|
|
367
|
+
[0, 0, 255],
|
|
368
|
+
[0, 255, 0],
|
|
369
|
+
[255, 0, 0],
|
|
370
|
+
];
|
|
371
|
+
for (let k = 0; k < 3; k++) {
|
|
372
|
+
const sx = finalWidth - 3 + k;
|
|
373
|
+
if (sx >= 0) {
|
|
374
|
+
const i = (lastYFinal * finalWidth + sx) * 4;
|
|
375
|
+
finalOut[i] = bgrSeq[k][0];
|
|
376
|
+
finalOut[i + 1] = bgrSeq[k][1];
|
|
377
|
+
finalOut[i + 2] = bgrSeq[k][2];
|
|
378
|
+
finalOut[i + 3] = 255;
|
|
379
|
+
}
|
|
342
380
|
}
|
|
343
381
|
}
|
|
344
382
|
return sharp(finalOut, {
|
|
@@ -359,14 +397,8 @@ export async function encodeBinaryToPng(input, opts = {}) {
|
|
|
359
397
|
let payload = Buffer.concat([MAGIC, input]);
|
|
360
398
|
const brQuality = typeof opts.brQuality === 'number' ? opts.brQuality : 11;
|
|
361
399
|
const mode = opts.mode === undefined ? 'screenshot' : opts.mode;
|
|
362
|
-
const
|
|
363
|
-
|
|
364
|
-
(mode === 'compact' || mode === 'pixel' || mode === 'screenshot'));
|
|
365
|
-
if (useBrotli) {
|
|
366
|
-
payload = zlib.brotliCompressSync(payload, {
|
|
367
|
-
params: { [zlib.constants.BROTLI_PARAM_QUALITY]: brQuality },
|
|
368
|
-
});
|
|
369
|
-
}
|
|
400
|
+
const compression = opts.compression || 'zstd';
|
|
401
|
+
payload = Buffer.from(await zstdCompress(payload, 22));
|
|
370
402
|
if (opts.passphrase && !opts.encrypt) {
|
|
371
403
|
opts.encrypt = 'aes';
|
|
372
404
|
}
|
|
@@ -443,9 +475,14 @@ export async function encodeBinaryToPng(input, opts = {}) {
|
|
|
443
475
|
? Buffer.concat([dataWithoutMarkers, Buffer.alloc(padding)])
|
|
444
476
|
: dataWithoutMarkers;
|
|
445
477
|
const markerStartBytes = colorsToBytes(MARKER_START);
|
|
446
|
-
const
|
|
478
|
+
const compressionMarkerBytes = colorsToBytes(COMPRESSION_MARKERS.zstd);
|
|
479
|
+
const dataWithMarkers = Buffer.concat([
|
|
480
|
+
markerStartBytes,
|
|
481
|
+
compressionMarkerBytes,
|
|
482
|
+
paddedData,
|
|
483
|
+
]);
|
|
447
484
|
const bytesPerPixel = 3;
|
|
448
|
-
const dataPixels = Math.ceil(
|
|
485
|
+
const dataPixels = Math.ceil(dataWithMarkers.length / 3);
|
|
449
486
|
let logicalWidth = Math.ceil(Math.sqrt(dataPixels));
|
|
450
487
|
if (logicalWidth < MARKER_END.length) {
|
|
451
488
|
logicalWidth = MARKER_END.length;
|
|
@@ -455,7 +492,7 @@ export async function encodeBinaryToPng(input, opts = {}) {
|
|
|
455
492
|
const spaceInLastRow = pixelsInLastRow === 0 ? logicalWidth : logicalWidth - pixelsInLastRow;
|
|
456
493
|
const needsExtraRow = spaceInLastRow < MARKER_END.length;
|
|
457
494
|
const logicalHeight = needsExtraRow ? dataRows + 1 : dataRows;
|
|
458
|
-
const scale =
|
|
495
|
+
const scale = 1;
|
|
459
496
|
const width = logicalWidth * scale;
|
|
460
497
|
const height = logicalHeight * scale;
|
|
461
498
|
const raw = Buffer.alloc(width * height * bytesPerPixel);
|
|
@@ -473,17 +510,14 @@ export async function encodeBinaryToPng(input, opts = {}) {
|
|
|
473
510
|
else if (ly < dataRows ||
|
|
474
511
|
(ly === dataRows && linearIdx < dataPixels)) {
|
|
475
512
|
const srcIdx = linearIdx * 3;
|
|
476
|
-
r =
|
|
477
|
-
srcIdx < dataWithMarkerStart.length
|
|
478
|
-
? dataWithMarkerStart[srcIdx]
|
|
479
|
-
: 0;
|
|
513
|
+
r = srcIdx < dataWithMarkers.length ? dataWithMarkers[srcIdx] : 0;
|
|
480
514
|
g =
|
|
481
|
-
srcIdx + 1 <
|
|
482
|
-
?
|
|
515
|
+
srcIdx + 1 < dataWithMarkers.length
|
|
516
|
+
? dataWithMarkers[srcIdx + 1]
|
|
483
517
|
: 0;
|
|
484
518
|
b =
|
|
485
|
-
srcIdx + 2 <
|
|
486
|
-
?
|
|
519
|
+
srcIdx + 2 < dataWithMarkers.length
|
|
520
|
+
? dataWithMarkers[srcIdx + 2]
|
|
487
521
|
: 0;
|
|
488
522
|
}
|
|
489
523
|
for (let sy = 0; sy < scale; sy++) {
|
|
@@ -628,13 +662,13 @@ export async function decodePngToBinary(pngBuf, opts = {}) {
|
|
|
628
662
|
const rawPayload = d.slice(idx);
|
|
629
663
|
let payload = tryDecryptIfNeeded(rawPayload, opts.passphrase);
|
|
630
664
|
try {
|
|
631
|
-
payload =
|
|
665
|
+
payload = await tryZstdDecompress(payload);
|
|
632
666
|
}
|
|
633
667
|
catch (e) {
|
|
634
668
|
const errMsg = e instanceof Error ? e.message : String(e);
|
|
635
669
|
if (opts.passphrase)
|
|
636
|
-
throw new Error('Incorrect passphrase (ROX format,
|
|
637
|
-
throw new Error('ROX format
|
|
670
|
+
throw new Error('Incorrect passphrase (ROX format, zstd failed: ' + errMsg + ')');
|
|
671
|
+
throw new Error('ROX format zstd decompression failed: ' + errMsg);
|
|
638
672
|
}
|
|
639
673
|
if (!payload.slice(0, MAGIC.length).equals(MAGIC)) {
|
|
640
674
|
throw new Error('Invalid ROX format (ROX direct: missing ROX1 magic after decompression)');
|
|
@@ -682,13 +716,13 @@ export async function decodePngToBinary(pngBuf, opts = {}) {
|
|
|
682
716
|
throw new DataFormatError('Compact mode payload empty');
|
|
683
717
|
let payload = tryDecryptIfNeeded(rawPayload, opts.passphrase);
|
|
684
718
|
try {
|
|
685
|
-
payload =
|
|
719
|
+
payload = await tryZstdDecompress(payload);
|
|
686
720
|
}
|
|
687
721
|
catch (e) {
|
|
688
722
|
const errMsg = e instanceof Error ? e.message : String(e);
|
|
689
723
|
if (opts.passphrase)
|
|
690
|
-
throw new IncorrectPassphraseError('Incorrect passphrase (compact mode,
|
|
691
|
-
throw new DataFormatError('Compact mode
|
|
724
|
+
throw new IncorrectPassphraseError('Incorrect passphrase (compact mode, zstd failed: ' + errMsg + ')');
|
|
725
|
+
throw new DataFormatError('Compact mode zstd decompression failed: ' + errMsg);
|
|
692
726
|
}
|
|
693
727
|
if (!payload.slice(0, MAGIC.length).equals(MAGIC)) {
|
|
694
728
|
throw new DataFormatError('Invalid ROX format (compact mode: missing ROX1 magic after decompression)');
|
|
@@ -748,7 +782,7 @@ export async function decodePngToBinary(pngBuf, opts = {}) {
|
|
|
748
782
|
logicalData = rawRGB;
|
|
749
783
|
}
|
|
750
784
|
else {
|
|
751
|
-
const reconstructed = await cropAndReconstitute(data);
|
|
785
|
+
const reconstructed = await cropAndReconstitute(data, opts.debugDir);
|
|
752
786
|
const { data: rdata, info: rinfo } = await sharp(reconstructed)
|
|
753
787
|
.ensureAlpha()
|
|
754
788
|
.raw()
|
|
@@ -794,14 +828,9 @@ export async function decodePngToBinary(pngBuf, opts = {}) {
|
|
|
794
828
|
const rawPayload = logicalData.slice(idx, idx + payloadLen);
|
|
795
829
|
let payload = tryDecryptIfNeeded(rawPayload, opts.passphrase);
|
|
796
830
|
try {
|
|
797
|
-
payload =
|
|
798
|
-
}
|
|
799
|
-
catch (e) {
|
|
800
|
-
const errMsg = e instanceof Error ? e.message : String(e);
|
|
801
|
-
if (opts.passphrase)
|
|
802
|
-
throw new IncorrectPassphraseError('Incorrect passphrase (pixel mode, brotli failed: ' + errMsg + ')');
|
|
803
|
-
throw new DataFormatError('Pixel mode brotli decompression failed: ' + errMsg);
|
|
831
|
+
payload = await tryZstdDecompress(payload);
|
|
804
832
|
}
|
|
833
|
+
catch (e) { }
|
|
805
834
|
if (!payload.slice(0, MAGIC.length).equals(MAGIC)) {
|
|
806
835
|
throw new DataFormatError('Invalid ROX format (pixel mode: missing ROX1 magic after decompression)');
|
|
807
836
|
}
|
|
@@ -931,6 +960,19 @@ export async function decodePngToBinary(pngBuf, opts = {}) {
|
|
|
931
960
|
throw new Error('Marker START not found - image format not supported');
|
|
932
961
|
}
|
|
933
962
|
}
|
|
963
|
+
let compression = 'zstd';
|
|
964
|
+
if (gridFromStart.length > MARKER_START.length) {
|
|
965
|
+
const compPixel = gridFromStart[MARKER_START.length];
|
|
966
|
+
if (compPixel.r === 0 && compPixel.g === 255 && compPixel.b === 0) {
|
|
967
|
+
compression = 'zstd';
|
|
968
|
+
}
|
|
969
|
+
else {
|
|
970
|
+
compression = 'zstd';
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
if (process.env.ROX_DEBUG) {
|
|
974
|
+
console.log(`DEBUG: Detected compression: ${compression}`);
|
|
975
|
+
}
|
|
934
976
|
let endStartIdx = -1;
|
|
935
977
|
const lastLineStart = (logicalHeight - 1) * logicalWidth;
|
|
936
978
|
const endMarkerStartCol = logicalWidth - MARKER_END.length;
|
|
@@ -966,7 +1008,7 @@ export async function decodePngToBinary(pngBuf, opts = {}) {
|
|
|
966
1008
|
}
|
|
967
1009
|
endStartIdx = gridFromStart.length;
|
|
968
1010
|
}
|
|
969
|
-
const dataGrid = gridFromStart.slice(MARKER_START.length, endStartIdx);
|
|
1011
|
+
const dataGrid = gridFromStart.slice(MARKER_START.length + 1, endStartIdx);
|
|
970
1012
|
const pixelBytes = Buffer.alloc(dataGrid.length * 3);
|
|
971
1013
|
for (let i = 0; i < dataGrid.length; i++) {
|
|
972
1014
|
pixelBytes[i * 3] = dataGrid[i].r;
|
|
@@ -1015,15 +1057,15 @@ export async function decodePngToBinary(pngBuf, opts = {}) {
|
|
|
1015
1057
|
const rawPayload = pixelBytes.slice(idx, idx + payloadLen);
|
|
1016
1058
|
let payload = tryDecryptIfNeeded(rawPayload, opts.passphrase);
|
|
1017
1059
|
try {
|
|
1018
|
-
payload =
|
|
1060
|
+
payload = await tryZstdDecompress(payload);
|
|
1019
1061
|
}
|
|
1020
1062
|
catch (e) {
|
|
1021
1063
|
const errMsg = e instanceof Error ? e.message : String(e);
|
|
1022
1064
|
if (opts.passphrase)
|
|
1023
|
-
throw new IncorrectPassphraseError(
|
|
1065
|
+
throw new IncorrectPassphraseError(`Incorrect passphrase (screenshot mode, zstd failed: ` +
|
|
1024
1066
|
errMsg +
|
|
1025
1067
|
')');
|
|
1026
|
-
throw new DataFormatError(
|
|
1068
|
+
throw new DataFormatError(`Screenshot mode zstd decompression failed: ` + errMsg);
|
|
1027
1069
|
}
|
|
1028
1070
|
if (!payload.slice(0, MAGIC.length).equals(MAGIC)) {
|
|
1029
1071
|
throw new DataFormatError('Invalid ROX format (pixel mode: missing ROX1 magic after decompression)');
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "roxify",
|
|
3
|
-
"version": "1.1.
|
|
4
|
-
"description": "Encode binary data into PNG images and decode them back. Supports CLI and programmatic API (Node.js ESM).",
|
|
3
|
+
"version": "1.1.3",
|
|
4
|
+
"description": "Encode binary data into PNG images with Zstd compression and decode them back. Supports CLI and programmatic API (Node.js ESM).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"types": "dist/index.d.ts",
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
"node": ">=18"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
+
"@mongodb-js/zstd": "^7.0.0",
|
|
43
44
|
"png-chunks-encode": "^1.0.0",
|
|
44
45
|
"png-chunks-extract": "^1.0.0",
|
|
45
46
|
"sharp": "^0.34.5"
|