roxify 1.0.0

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/dist/index.js ADDED
@@ -0,0 +1,1042 @@
1
+ import { createCipheriv, createDecipheriv, pbkdf2Sync, randomBytes, } from 'crypto';
2
+ import encode from 'png-chunks-encode';
3
+ import extract from 'png-chunks-extract';
4
+ import sharp from 'sharp';
5
+ import * as zlib from 'zlib';
6
+ const CHUNK_TYPE = 'rXDT';
7
+ const MAGIC = Buffer.from('ROX1');
8
+ const PIXEL_MAGIC = Buffer.from('PXL1');
9
+ const ENC_NONE = 0;
10
+ const ENC_AES = 1;
11
+ const ENC_XOR = 2;
12
+ export class PassphraseRequiredError extends Error {
13
+ constructor(message = 'Passphrase required') {
14
+ super(message);
15
+ this.name = 'PassphraseRequiredError';
16
+ }
17
+ }
18
+ export class IncorrectPassphraseError extends Error {
19
+ constructor(message = 'Incorrect passphrase') {
20
+ super(message);
21
+ this.name = 'IncorrectPassphraseError';
22
+ }
23
+ }
24
+ export class DataFormatError extends Error {
25
+ constructor(message = 'Data format error') {
26
+ super(message);
27
+ this.name = 'DataFormatError';
28
+ }
29
+ }
30
+ const PNG_HEADER = Buffer.from([
31
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
32
+ ]);
33
+ const PNG_HEADER_HEX = PNG_HEADER.toString('hex');
34
+ const MARKER_COLORS = [
35
+ { r: 255, g: 0, b: 0 },
36
+ { r: 0, g: 255, b: 0 },
37
+ { r: 0, g: 0, b: 255 },
38
+ ];
39
+ const MARKER_START = MARKER_COLORS;
40
+ const MARKER_END = [...MARKER_COLORS].reverse();
41
+ function colorsToBytes(colors) {
42
+ const buf = Buffer.alloc(colors.length * 3);
43
+ for (let i = 0; i < colors.length; i++) {
44
+ buf[i * 3] = colors[i].r;
45
+ buf[i * 3 + 1] = colors[i].g;
46
+ buf[i * 3 + 2] = colors[i].b;
47
+ }
48
+ return buf;
49
+ }
50
+ function applyXor(buf, passphrase) {
51
+ const key = Buffer.from(passphrase, 'utf8');
52
+ const out = Buffer.alloc(buf.length);
53
+ for (let i = 0; i < buf.length; i++) {
54
+ out[i] = buf[i] ^ key[i % key.length];
55
+ }
56
+ return out;
57
+ }
58
+ function tryBrotliDecompress(payload) {
59
+ return Buffer.from(zlib.brotliDecompressSync(payload));
60
+ }
61
+ function tryDecryptIfNeeded(buf, passphrase) {
62
+ if (!buf || buf.length === 0)
63
+ return buf;
64
+ const flag = buf[0];
65
+ if (flag === ENC_AES) {
66
+ const MIN_AES_LEN = 1 + 16 + 12 + 16 + 1;
67
+ if (buf.length < MIN_AES_LEN)
68
+ throw new IncorrectPassphraseError();
69
+ if (!passphrase)
70
+ throw new PassphraseRequiredError();
71
+ const salt = buf.slice(1, 17);
72
+ const iv = buf.slice(17, 29);
73
+ const tag = buf.slice(29, 45);
74
+ const enc = buf.slice(45);
75
+ const PBKDF2_ITERS = 1000000;
76
+ const key = pbkdf2Sync(passphrase, salt, PBKDF2_ITERS, 32, 'sha256');
77
+ const dec = createDecipheriv('aes-256-gcm', key, iv);
78
+ dec.setAuthTag(tag);
79
+ try {
80
+ const decrypted = Buffer.concat([dec.update(enc), dec.final()]);
81
+ return decrypted;
82
+ }
83
+ catch (e) {
84
+ throw new IncorrectPassphraseError();
85
+ }
86
+ }
87
+ if (flag === ENC_XOR) {
88
+ if (!passphrase)
89
+ throw new PassphraseRequiredError();
90
+ return applyXor(buf.slice(1), passphrase);
91
+ }
92
+ if (flag === ENC_NONE) {
93
+ return buf.slice(1);
94
+ }
95
+ return buf;
96
+ }
97
+ function idxFor(x, y, width) {
98
+ return (y * width + x) * 4;
99
+ }
100
+ function eqRGB(a, b) {
101
+ return a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
102
+ }
103
+ async function loadRaw(imgInput) {
104
+ const { data, info } = await sharp(imgInput)
105
+ .ensureAlpha()
106
+ .raw()
107
+ .toBuffer({ resolveWithObject: true });
108
+ return { data, info };
109
+ }
110
+ export async function cropAndReconstitute(input) {
111
+ const { data, info } = await loadRaw(input);
112
+ const w = info.width;
113
+ const h = info.height;
114
+ function at(x, y) {
115
+ const i = idxFor(x, y, w);
116
+ return [data[i], data[i + 1], data[i + 2], data[i + 3]];
117
+ }
118
+ let startPoint = null;
119
+ for (let y = 0; y < h && !startPoint; y++) {
120
+ for (let x = 0; x < w; x++) {
121
+ const p = at(x, y);
122
+ if (p[0] !== 255 || p[1] !== 0 || p[2] !== 0)
123
+ continue;
124
+ let nx = x + 1;
125
+ while (nx < w && eqRGB(at(nx, y), p))
126
+ nx++;
127
+ if (nx >= w)
128
+ continue;
129
+ const a = at(nx, y);
130
+ let nx2 = nx + 1;
131
+ while (nx2 < w && eqRGB(at(nx2, y), a))
132
+ nx2++;
133
+ if (nx2 >= w)
134
+ continue;
135
+ const b = at(nx2, y);
136
+ const isRgb = a[0] === 0 &&
137
+ a[1] === 255 &&
138
+ a[2] === 0 &&
139
+ b[0] === 0 &&
140
+ b[1] === 0 &&
141
+ b[2] === 255;
142
+ if (isRgb) {
143
+ startPoint = { x, y, type: 'rgb' };
144
+ break;
145
+ }
146
+ }
147
+ }
148
+ let endPoint = null;
149
+ for (let y = h - 1; y >= 0 && !endPoint; y--) {
150
+ for (let x = w - 1; x >= 0; x--) {
151
+ const p = at(x, y);
152
+ if (p[0] !== 255 || p[1] !== 0 || p[2] !== 0)
153
+ continue;
154
+ let nx = x - 1;
155
+ while (nx >= 0 && eqRGB(at(nx, y), p))
156
+ nx--;
157
+ if (nx < 0)
158
+ continue;
159
+ const a = at(nx, y);
160
+ let nx2 = nx - 1;
161
+ while (nx2 >= 0 && eqRGB(at(nx2, y), a))
162
+ nx2--;
163
+ if (nx2 < 0)
164
+ continue;
165
+ const b = at(nx2, y);
166
+ const isRgbReverse = a[0] === 0 &&
167
+ a[1] === 255 &&
168
+ a[2] === 0 &&
169
+ b[0] === 0 &&
170
+ b[1] === 0 &&
171
+ b[2] === 255;
172
+ if (isRgbReverse) {
173
+ endPoint = { x, y, type: 'bgr' };
174
+ break;
175
+ }
176
+ }
177
+ }
178
+ if (!startPoint)
179
+ throw new Error('Start pattern (RGB) not found');
180
+ if (!endPoint)
181
+ throw new Error('End pattern (BGR) not found');
182
+ const sx1 = Math.min(startPoint.x, endPoint.x);
183
+ const sy1 = Math.min(startPoint.y, endPoint.y);
184
+ const sx2 = Math.max(startPoint.x, endPoint.x);
185
+ const sy2 = Math.max(startPoint.y, endPoint.y);
186
+ const cropW = sx2 - sx1;
187
+ const cropH = sy2 - sy1;
188
+ if (cropW <= 0 || cropH <= 0)
189
+ throw new Error('Invalid crop dimensions');
190
+ const cropped = await sharp(input)
191
+ .extract({ left: sx1, top: sy1, width: cropW, height: cropH })
192
+ .png()
193
+ .toBuffer();
194
+ const { data: cdata, info: cinfo } = await sharp(cropped)
195
+ .ensureAlpha()
196
+ .raw()
197
+ .toBuffer({ resolveWithObject: true });
198
+ const cw = cinfo.width;
199
+ const ch = cinfo.height;
200
+ function cat(x, y) {
201
+ const i = idxFor(x, y, cw);
202
+ return [cdata[i], cdata[i + 1], cdata[i + 2], cdata[i + 3]];
203
+ }
204
+ function eq(a, b) {
205
+ return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3];
206
+ }
207
+ function lineEq(l1, l2) {
208
+ if (l1.length !== l2.length)
209
+ return false;
210
+ for (let i = 0; i < l1.length; i++)
211
+ if (!eq(l1[i], l2[i]))
212
+ return false;
213
+ return true;
214
+ }
215
+ const compressedLines = [];
216
+ for (let y = 0; y < ch; y++) {
217
+ const line = [];
218
+ let x = 0;
219
+ while (x < cw) {
220
+ const current = cat(x, y);
221
+ if (current[0] === 0 && current[1] === 0 && current[2] === 0) {
222
+ x++;
223
+ continue;
224
+ }
225
+ line.push(current);
226
+ let nx = x + 1;
227
+ while (nx < cw && eq(cat(nx, y), current))
228
+ nx++;
229
+ x = nx;
230
+ }
231
+ if (line.length === 0)
232
+ continue;
233
+ if (compressedLines.length === 0 ||
234
+ !lineEq(compressedLines[compressedLines.length - 1], line))
235
+ compressedLines.push(line);
236
+ }
237
+ if (compressedLines.length === 0) {
238
+ return sharp({
239
+ create: {
240
+ width: 1,
241
+ height: 1,
242
+ channels: 4,
243
+ background: { r: 0, g: 0, b: 0, alpha: 1 },
244
+ },
245
+ })
246
+ .png()
247
+ .toBuffer();
248
+ }
249
+ const newWidth = Math.max(...compressedLines.map((l) => l.length));
250
+ const newHeight = compressedLines.length + 1;
251
+ const out = Buffer.alloc(newWidth * newHeight * 4, 0);
252
+ for (let i = 0; i < out.length; i += 4)
253
+ out[i + 3] = 255;
254
+ for (let y = 0; y < compressedLines.length; y++) {
255
+ const line = compressedLines[y];
256
+ const isSecondToLast = y === compressedLines.length - 1;
257
+ const startX = 0;
258
+ const effectiveLength = isSecondToLast
259
+ ? Math.max(0, line.length - 3)
260
+ : line.length;
261
+ for (let x = 0; x < effectiveLength; x++) {
262
+ const i = ((y * newWidth + startX + x) * 4) | 0;
263
+ out[i] = line[x][0];
264
+ out[i + 1] = line[x][1];
265
+ out[i + 2] = line[x][2];
266
+ out[i + 3] = line[x][3] === 0 ? 255 : line[x][3];
267
+ }
268
+ if (isSecondToLast) {
269
+ for (let x = effectiveLength; x < Math.min(effectiveLength + 3, newWidth); x++) {
270
+ const i = ((y * newWidth + x) * 4) | 0;
271
+ out[i] = 0;
272
+ out[i + 1] = 0;
273
+ out[i + 2] = 0;
274
+ out[i + 3] = 255;
275
+ }
276
+ }
277
+ }
278
+ const secondToLastY = newHeight - 2;
279
+ let secondToLastIsBlack = true;
280
+ for (let x = 0; x < newWidth; x++) {
281
+ const i = ((secondToLastY * newWidth + x) * 4) | 0;
282
+ if (out[i] !== 0 || out[i + 1] !== 0 || out[i + 2] !== 0) {
283
+ secondToLastIsBlack = false;
284
+ break;
285
+ }
286
+ }
287
+ let finalHeight = newHeight;
288
+ let finalOut = out;
289
+ if (secondToLastIsBlack) {
290
+ finalHeight = newHeight - 1;
291
+ finalOut = Buffer.alloc(newWidth * finalHeight * 4, 0);
292
+ for (let i = 0; i < finalOut.length; i += 4)
293
+ finalOut[i + 3] = 255;
294
+ for (let y = 0; y < newHeight - 2; y++) {
295
+ for (let x = 0; x < newWidth; x++) {
296
+ const srcI = ((y * newWidth + x) * 4) | 0;
297
+ const dstI = ((y * newWidth + x) * 4) | 0;
298
+ finalOut[dstI] = out[srcI];
299
+ finalOut[dstI + 1] = out[srcI + 1];
300
+ finalOut[dstI + 2] = out[srcI + 2];
301
+ finalOut[dstI + 3] = out[srcI + 3];
302
+ }
303
+ }
304
+ }
305
+ const lastY = finalHeight - 1;
306
+ for (let x = 0; x < newWidth; x++) {
307
+ const i = ((lastY * newWidth + x) * 4) | 0;
308
+ finalOut[i] = 0;
309
+ finalOut[i + 1] = 0;
310
+ finalOut[i + 2] = 0;
311
+ finalOut[i + 3] = 255;
312
+ }
313
+ if (newWidth >= 3) {
314
+ const bgrStart = newWidth - 3;
315
+ let i = ((lastY * newWidth + bgrStart) * 4) | 0;
316
+ finalOut[i] = 0;
317
+ finalOut[i + 1] = 0;
318
+ finalOut[i + 2] = 255;
319
+ finalOut[i + 3] = 255;
320
+ i = ((lastY * newWidth + bgrStart + 1) * 4) | 0;
321
+ finalOut[i] = 0;
322
+ finalOut[i + 1] = 255;
323
+ finalOut[i + 2] = 0;
324
+ finalOut[i + 3] = 255;
325
+ i = ((lastY * newWidth + bgrStart + 2) * 4) | 0;
326
+ finalOut[i] = 255;
327
+ finalOut[i + 1] = 0;
328
+ finalOut[i + 2] = 0;
329
+ finalOut[i + 3] = 255;
330
+ }
331
+ return sharp(finalOut, {
332
+ raw: { width: newWidth, height: finalHeight, channels: 4 },
333
+ })
334
+ .png()
335
+ .toBuffer();
336
+ }
337
+ /**
338
+ * Encode a Buffer into a PNG wrapper. Supports optional compression and
339
+ * encryption. Defaults are chosen for a good balance between speed and size.
340
+ *
341
+ * @param input - Data to encode
342
+ * @param opts - Encoding options
343
+ * @public
344
+ */
345
+ export async function encodeBinaryToPng(input, opts = {}) {
346
+ let payload = Buffer.concat([MAGIC, input]);
347
+ const brQuality = typeof opts.brQuality === 'number' ? opts.brQuality : 11;
348
+ const mode = opts.mode === undefined ? 'screenshot' : opts.mode;
349
+ const useBrotli = opts.compression === 'br' ||
350
+ (opts.compression === undefined &&
351
+ (mode === 'compact' || mode === 'pixel' || mode === 'screenshot'));
352
+ if (useBrotli) {
353
+ payload = zlib.brotliCompressSync(payload, {
354
+ params: { [zlib.constants.BROTLI_PARAM_QUALITY]: brQuality },
355
+ });
356
+ }
357
+ if (opts.passphrase && !opts.encrypt) {
358
+ opts.encrypt = 'aes';
359
+ }
360
+ if (opts.encrypt === 'auto' && !opts._skipAuto) {
361
+ const candidates = ['none', 'xor', 'aes'];
362
+ const candidateBufs = [];
363
+ for (const c of candidates) {
364
+ const testBuf = await encodeBinaryToPng(input, {
365
+ ...opts,
366
+ encrypt: c,
367
+ _skipAuto: true,
368
+ });
369
+ candidateBufs.push({ enc: c, buf: testBuf });
370
+ }
371
+ candidateBufs.sort((a, b) => a.buf.length - b.buf.length);
372
+ return candidateBufs[0].buf;
373
+ }
374
+ if (opts.passphrase && opts.encrypt && opts.encrypt !== 'auto') {
375
+ const encChoice = opts.encrypt;
376
+ if (encChoice === 'aes') {
377
+ const salt = randomBytes(16);
378
+ const iv = randomBytes(12);
379
+ const PBKDF2_ITERS = 1000000;
380
+ const key = pbkdf2Sync(opts.passphrase, salt, PBKDF2_ITERS, 32, 'sha256');
381
+ const cipher = createCipheriv('aes-256-gcm', key, iv);
382
+ const enc = Buffer.concat([cipher.update(payload), cipher.final()]);
383
+ const tag = cipher.getAuthTag();
384
+ payload = Buffer.concat([Buffer.from([ENC_AES]), salt, iv, tag, enc]);
385
+ }
386
+ else if (encChoice === 'xor') {
387
+ const xored = applyXor(payload, opts.passphrase);
388
+ payload = Buffer.concat([Buffer.from([ENC_XOR]), xored]);
389
+ }
390
+ else if (encChoice === 'none') {
391
+ payload = Buffer.concat([Buffer.from([ENC_NONE]), payload]);
392
+ }
393
+ }
394
+ else {
395
+ payload = Buffer.concat([Buffer.from([ENC_NONE]), payload]);
396
+ }
397
+ const metaParts = [];
398
+ const includeName = opts.includeName === undefined ? true : !!opts.includeName;
399
+ if (includeName && opts.name) {
400
+ const nameBuf = Buffer.from(opts.name, 'utf8');
401
+ metaParts.push(Buffer.from([nameBuf.length]));
402
+ metaParts.push(nameBuf);
403
+ }
404
+ else {
405
+ metaParts.push(Buffer.from([0]));
406
+ }
407
+ metaParts.push(payload);
408
+ const meta = Buffer.concat(metaParts);
409
+ if (opts.output === 'rox') {
410
+ return Buffer.concat([MAGIC, meta]);
411
+ }
412
+ if (mode === 'screenshot') {
413
+ const nameBuf = opts.name
414
+ ? Buffer.from(opts.name, 'utf8')
415
+ : Buffer.alloc(0);
416
+ const nameLen = nameBuf.length;
417
+ const payloadLenBuf = Buffer.alloc(4);
418
+ payloadLenBuf.writeUInt32BE(payload.length, 0);
419
+ const version = 2;
420
+ const metaPixel = Buffer.concat([
421
+ Buffer.from([version]),
422
+ Buffer.from([nameLen]),
423
+ nameBuf,
424
+ payloadLenBuf,
425
+ payload,
426
+ ]);
427
+ const dataWithoutMarkers = Buffer.concat([PIXEL_MAGIC, metaPixel]);
428
+ const padding = (3 - (dataWithoutMarkers.length % 3)) % 3;
429
+ const paddedData = padding > 0
430
+ ? Buffer.concat([dataWithoutMarkers, Buffer.alloc(padding)])
431
+ : dataWithoutMarkers;
432
+ const markerStartBytes = colorsToBytes(MARKER_START);
433
+ const dataWithMarkerStart = Buffer.concat([markerStartBytes, paddedData]);
434
+ const bytesPerPixel = 3;
435
+ const dataPixels = Math.ceil(dataWithMarkerStart.length / 3);
436
+ let logicalWidth = Math.ceil(Math.sqrt(dataPixels));
437
+ if (logicalWidth < MARKER_END.length) {
438
+ logicalWidth = MARKER_END.length;
439
+ }
440
+ const dataRows = Math.ceil(dataPixels / logicalWidth);
441
+ const pixelsInLastRow = dataPixels % logicalWidth;
442
+ const spaceInLastRow = pixelsInLastRow === 0 ? logicalWidth : logicalWidth - pixelsInLastRow;
443
+ const needsExtraRow = spaceInLastRow < MARKER_END.length;
444
+ const logicalHeight = needsExtraRow ? dataRows + 1 : dataRows;
445
+ const scale = 1;
446
+ const width = logicalWidth * scale;
447
+ const height = logicalHeight * scale;
448
+ const raw = Buffer.alloc(width * height * bytesPerPixel);
449
+ for (let ly = 0; ly < logicalHeight; ly++) {
450
+ for (let lx = 0; lx < logicalWidth; lx++) {
451
+ const linearIdx = ly * logicalWidth + lx;
452
+ let r = 0, g = 0, b = 0;
453
+ if (ly === logicalHeight - 1 &&
454
+ lx >= logicalWidth - MARKER_END.length) {
455
+ const markerIdx = lx - (logicalWidth - MARKER_END.length);
456
+ r = MARKER_END[markerIdx].r;
457
+ g = MARKER_END[markerIdx].g;
458
+ b = MARKER_END[markerIdx].b;
459
+ }
460
+ else if (ly < dataRows ||
461
+ (ly === dataRows && linearIdx < dataPixels)) {
462
+ const srcIdx = linearIdx * 3;
463
+ r =
464
+ srcIdx < dataWithMarkerStart.length
465
+ ? dataWithMarkerStart[srcIdx]
466
+ : 0;
467
+ g =
468
+ srcIdx + 1 < dataWithMarkerStart.length
469
+ ? dataWithMarkerStart[srcIdx + 1]
470
+ : 0;
471
+ b =
472
+ srcIdx + 2 < dataWithMarkerStart.length
473
+ ? dataWithMarkerStart[srcIdx + 2]
474
+ : 0;
475
+ }
476
+ for (let sy = 0; sy < scale; sy++) {
477
+ for (let sx = 0; sx < scale; sx++) {
478
+ const px = lx * scale + sx;
479
+ const py = ly * scale + sy;
480
+ const dstIdx = (py * width + px) * 3;
481
+ raw[dstIdx] = r;
482
+ raw[dstIdx + 1] = g;
483
+ raw[dstIdx + 2] = b;
484
+ }
485
+ }
486
+ }
487
+ }
488
+ return await sharp(raw, {
489
+ raw: { width, height, channels: 3 },
490
+ })
491
+ .png({
492
+ compressionLevel: 0,
493
+ palette: false,
494
+ effort: 1,
495
+ adaptiveFiltering: false,
496
+ })
497
+ .toBuffer();
498
+ }
499
+ if (mode === 'pixel') {
500
+ const nameBuf = opts.name
501
+ ? Buffer.from(opts.name, 'utf8')
502
+ : Buffer.alloc(0);
503
+ const nameLen = nameBuf.length;
504
+ const payloadLenBuf = Buffer.alloc(4);
505
+ payloadLenBuf.writeUInt32BE(payload.length, 0);
506
+ const version = 1;
507
+ const metaPixel = Buffer.concat([
508
+ Buffer.from([version]),
509
+ Buffer.from([nameLen]),
510
+ nameBuf,
511
+ payloadLenBuf,
512
+ payload,
513
+ ]);
514
+ const full = Buffer.concat([PIXEL_MAGIC, metaPixel]);
515
+ const bytesPerPixel = 3;
516
+ const nPixels = Math.ceil((full.length + 8) / 3);
517
+ const side = Math.ceil(Math.sqrt(nPixels));
518
+ const width = Math.max(1, Math.min(side, 65535));
519
+ const height = Math.ceil(nPixels / width);
520
+ const dimHeader = Buffer.alloc(8);
521
+ dimHeader.writeUInt32BE(width, 0);
522
+ dimHeader.writeUInt32BE(height, 4);
523
+ const fullWithDim = Buffer.concat([dimHeader, full]);
524
+ const rowLen = 1 + width * bytesPerPixel;
525
+ const raw = Buffer.alloc(rowLen * height);
526
+ for (let y = 0; y < height; y++) {
527
+ const rowOffset = y * rowLen;
528
+ raw[rowOffset] = 0;
529
+ for (let x = 0; x < width; x++) {
530
+ const srcIdx = (y * width + x) * 3;
531
+ const dstIdx = rowOffset + 1 + x * bytesPerPixel;
532
+ raw[dstIdx] = srcIdx < fullWithDim.length ? fullWithDim[srcIdx] : 0;
533
+ raw[dstIdx + 1] =
534
+ srcIdx + 1 < fullWithDim.length ? fullWithDim[srcIdx + 1] : 0;
535
+ raw[dstIdx + 2] =
536
+ srcIdx + 2 < fullWithDim.length ? fullWithDim[srcIdx + 2] : 0;
537
+ }
538
+ }
539
+ const idatData = zlib.deflateSync(raw, {
540
+ level: 6,
541
+ memLevel: 8,
542
+ strategy: zlib.constants.Z_RLE,
543
+ });
544
+ const ihdrData = Buffer.alloc(13);
545
+ ihdrData.writeUInt32BE(width, 0);
546
+ ihdrData.writeUInt32BE(height, 4);
547
+ ihdrData[8] = 8;
548
+ ihdrData[9] = 2;
549
+ ihdrData[10] = 0;
550
+ ihdrData[11] = 0;
551
+ ihdrData[12] = 0;
552
+ const chunksPixel = [];
553
+ chunksPixel.push({ name: 'IHDR', data: ihdrData });
554
+ chunksPixel.push({ name: 'IDAT', data: idatData });
555
+ chunksPixel.push({ name: 'IEND', data: Buffer.alloc(0) });
556
+ const tmp = Buffer.from(encode(chunksPixel));
557
+ const outPng = tmp.slice(0, 8).toString('hex') === PNG_HEADER_HEX
558
+ ? tmp
559
+ : Buffer.concat([PNG_HEADER, tmp]);
560
+ return outPng;
561
+ }
562
+ if (mode === 'compact') {
563
+ const bytesPerPixel = 4;
564
+ const side = 1;
565
+ const width = side;
566
+ const height = side;
567
+ const rowLen = 1 + width * bytesPerPixel;
568
+ const raw = Buffer.alloc(rowLen * height);
569
+ for (let y = 0; y < height; y++) {
570
+ raw[y * rowLen] = 0;
571
+ }
572
+ const idatData = zlib.deflateSync(raw, {
573
+ level: 9,
574
+ memLevel: 9,
575
+ strategy: zlib.constants.Z_DEFAULT_STRATEGY,
576
+ });
577
+ const ihdrData = Buffer.alloc(13);
578
+ ihdrData.writeUInt32BE(width, 0);
579
+ ihdrData.writeUInt32BE(height, 4);
580
+ ihdrData[8] = 8;
581
+ ihdrData[9] = 6;
582
+ ihdrData[10] = 0;
583
+ ihdrData[11] = 0;
584
+ ihdrData[12] = 0;
585
+ const chunks2 = [];
586
+ chunks2.push({ name: 'IHDR', data: ihdrData });
587
+ chunks2.push({ name: 'IDAT', data: idatData });
588
+ chunks2.push({ name: CHUNK_TYPE, data: meta });
589
+ chunks2.push({ name: 'IEND', data: Buffer.alloc(0) });
590
+ const out = Buffer.from(encode(chunks2));
591
+ return out.slice(0, 8).toString('hex') === PNG_HEADER_HEX
592
+ ? out
593
+ : Buffer.concat([PNG_HEADER, out]);
594
+ }
595
+ throw new Error(`Unsupported mode: ${mode}`);
596
+ }
597
+ /**
598
+ * Decode a PNG produced by this library back to the original Buffer.
599
+ * Supports the ROX binary format, rXDT chunk, and pixel encodings.
600
+ *
601
+ * @param pngBuf - PNG data
602
+ * @param opts - Options (passphrase for encrypted inputs)
603
+ * @public
604
+ */
605
+ export async function decodePngToBinary(pngBuf, opts = {}) {
606
+ if (pngBuf.slice(0, MAGIC.length).equals(MAGIC)) {
607
+ const d = pngBuf.slice(MAGIC.length);
608
+ const nameLen = d[0];
609
+ let idx = 1;
610
+ let name;
611
+ if (nameLen > 0) {
612
+ name = d.slice(idx, idx + nameLen).toString('utf8');
613
+ idx += nameLen;
614
+ }
615
+ const rawPayload = d.slice(idx);
616
+ let payload = tryDecryptIfNeeded(rawPayload, opts.passphrase);
617
+ try {
618
+ payload = tryBrotliDecompress(payload);
619
+ }
620
+ catch (e) {
621
+ const errMsg = e instanceof Error ? e.message : String(e);
622
+ if (opts.passphrase)
623
+ throw new Error('Incorrect passphrase (ROX format, brotli failed: ' + errMsg + ')');
624
+ throw new Error('ROX format brotli decompression failed: ' + errMsg);
625
+ }
626
+ if (!payload.slice(0, MAGIC.length).equals(MAGIC)) {
627
+ throw new Error('Invalid ROX format (ROX direct: missing ROX1 magic after decompression)');
628
+ }
629
+ payload = payload.slice(MAGIC.length);
630
+ return { buf: payload, meta: { name } };
631
+ }
632
+ let chunks = [];
633
+ try {
634
+ const chunksRaw = extract(pngBuf);
635
+ chunks = chunksRaw.map((c) => ({
636
+ name: c.name,
637
+ data: Buffer.isBuffer(c.data)
638
+ ? c.data
639
+ : Buffer.from(c.data),
640
+ }));
641
+ }
642
+ catch (e) {
643
+ try {
644
+ const withHeader = Buffer.concat([PNG_HEADER, pngBuf]);
645
+ const chunksRaw = extract(withHeader);
646
+ chunks = chunksRaw.map((c) => ({
647
+ name: c.name,
648
+ data: Buffer.isBuffer(c.data)
649
+ ? c.data
650
+ : Buffer.from(c.data),
651
+ }));
652
+ }
653
+ catch (e2) {
654
+ chunks = [];
655
+ }
656
+ }
657
+ const target = chunks.find((c) => c.name === CHUNK_TYPE);
658
+ if (target) {
659
+ const d = target.data;
660
+ const nameLen = d[0];
661
+ let idx = 1;
662
+ let name;
663
+ if (nameLen > 0) {
664
+ name = d.slice(idx, idx + nameLen).toString('utf8');
665
+ idx += nameLen;
666
+ }
667
+ const rawPayload = d.slice(idx);
668
+ if (rawPayload.length === 0)
669
+ throw new DataFormatError('Compact mode payload empty');
670
+ let payload = tryDecryptIfNeeded(rawPayload, opts.passphrase);
671
+ try {
672
+ payload = tryBrotliDecompress(payload);
673
+ }
674
+ catch (e) {
675
+ const errMsg = e instanceof Error ? e.message : String(e);
676
+ if (opts.passphrase)
677
+ throw new IncorrectPassphraseError('Incorrect passphrase (compact mode, brotli failed: ' + errMsg + ')');
678
+ throw new DataFormatError('Compact mode brotli decompression failed: ' + errMsg);
679
+ }
680
+ if (!payload.slice(0, MAGIC.length).equals(MAGIC)) {
681
+ throw new DataFormatError('Invalid ROX format (compact mode: missing ROX1 magic after decompression)');
682
+ }
683
+ payload = payload.slice(MAGIC.length);
684
+ return { buf: payload, meta: { name } };
685
+ }
686
+ try {
687
+ const { data, info } = await sharp(pngBuf)
688
+ .ensureAlpha()
689
+ .raw()
690
+ .toBuffer({ resolveWithObject: true });
691
+ const currentWidth = info.width;
692
+ const currentHeight = info.height;
693
+ const rawRGB = Buffer.alloc(currentWidth * currentHeight * 3);
694
+ for (let i = 0; i < currentWidth * currentHeight; i++) {
695
+ rawRGB[i * 3] = data[i * 4];
696
+ rawRGB[i * 3 + 1] = data[i * 4 + 1];
697
+ rawRGB[i * 3 + 2] = data[i * 4 + 2];
698
+ }
699
+ const firstPixels = [];
700
+ for (let i = 0; i < Math.min(MARKER_START.length, rawRGB.length / 3); i++) {
701
+ firstPixels.push({
702
+ r: rawRGB[i * 3],
703
+ g: rawRGB[i * 3 + 1],
704
+ b: rawRGB[i * 3 + 2],
705
+ });
706
+ }
707
+ let hasMarkerStart = false;
708
+ if (firstPixels.length === MARKER_START.length) {
709
+ hasMarkerStart = true;
710
+ for (let i = 0; i < MARKER_START.length; i++) {
711
+ if (firstPixels[i].r !== MARKER_START[i].r ||
712
+ firstPixels[i].g !== MARKER_START[i].g ||
713
+ firstPixels[i].b !== MARKER_START[i].b) {
714
+ hasMarkerStart = false;
715
+ break;
716
+ }
717
+ }
718
+ }
719
+ let hasPixelMagic = false;
720
+ if (rawRGB.length >= 8 + PIXEL_MAGIC.length) {
721
+ const widthFromDim = rawRGB.readUInt32BE(0);
722
+ const heightFromDim = rawRGB.readUInt32BE(4);
723
+ if (widthFromDim === currentWidth &&
724
+ heightFromDim === currentHeight &&
725
+ rawRGB.slice(8, 8 + PIXEL_MAGIC.length).equals(PIXEL_MAGIC)) {
726
+ hasPixelMagic = true;
727
+ }
728
+ }
729
+ let logicalWidth;
730
+ let logicalHeight;
731
+ let logicalData;
732
+ if (hasMarkerStart || hasPixelMagic) {
733
+ logicalWidth = currentWidth;
734
+ logicalHeight = currentHeight;
735
+ logicalData = rawRGB;
736
+ }
737
+ else {
738
+ const reconstructed = await cropAndReconstitute(data);
739
+ const { data: rdata, info: rinfo } = await sharp(reconstructed)
740
+ .ensureAlpha()
741
+ .raw()
742
+ .toBuffer({ resolveWithObject: true });
743
+ logicalWidth = rinfo.width;
744
+ logicalHeight = rinfo.height;
745
+ logicalData = Buffer.alloc(rinfo.width * rinfo.height * 3);
746
+ for (let i = 0; i < rinfo.width * rinfo.height; i++) {
747
+ logicalData[i * 3] = rdata[i * 4];
748
+ logicalData[i * 3 + 1] = rdata[i * 4 + 1];
749
+ logicalData[i * 3 + 2] = rdata[i * 4 + 2];
750
+ }
751
+ }
752
+ if (process.env.ROX_DEBUG) {
753
+ console.log('DEBUG: Logical grid reconstructed:', logicalWidth, 'x', logicalHeight, '=', logicalWidth * logicalHeight, 'pixels');
754
+ }
755
+ const finalGrid = [];
756
+ for (let i = 0; i < logicalData.length; i += 3) {
757
+ finalGrid.push({
758
+ r: logicalData[i],
759
+ g: logicalData[i + 1],
760
+ b: logicalData[i + 2],
761
+ });
762
+ }
763
+ if (hasPixelMagic) {
764
+ if (logicalData.length < 8 + PIXEL_MAGIC.length) {
765
+ throw new DataFormatError('Pixel mode data too short');
766
+ }
767
+ let idx = 8 + PIXEL_MAGIC.length;
768
+ const version = logicalData[idx++];
769
+ const nameLen = logicalData[idx++];
770
+ let name;
771
+ if (nameLen > 0 && nameLen < 256) {
772
+ name = logicalData.slice(idx, idx + nameLen).toString('utf8');
773
+ idx += nameLen;
774
+ }
775
+ const payloadLen = logicalData.readUInt32BE(idx);
776
+ idx += 4;
777
+ const available = logicalData.length - idx;
778
+ if (available < payloadLen) {
779
+ throw new DataFormatError(`Pixel payload truncated: expected ${payloadLen} bytes but only ${available} available`);
780
+ }
781
+ const rawPayload = logicalData.slice(idx, idx + payloadLen);
782
+ let payload = tryDecryptIfNeeded(rawPayload, opts.passphrase);
783
+ try {
784
+ payload = tryBrotliDecompress(payload);
785
+ }
786
+ catch (e) {
787
+ const errMsg = e instanceof Error ? e.message : String(e);
788
+ if (opts.passphrase)
789
+ throw new IncorrectPassphraseError('Incorrect passphrase (pixel mode, brotli failed: ' + errMsg + ')');
790
+ throw new DataFormatError('Pixel mode brotli decompression failed: ' + errMsg);
791
+ }
792
+ if (!payload.slice(0, MAGIC.length).equals(MAGIC)) {
793
+ throw new DataFormatError('Invalid ROX format (pixel mode: missing ROX1 magic after decompression)');
794
+ }
795
+ payload = payload.slice(MAGIC.length);
796
+ return { buf: payload, meta: { name } };
797
+ }
798
+ let startIdx = -1;
799
+ for (let i = 0; i <= finalGrid.length - MARKER_START.length; i++) {
800
+ let match = true;
801
+ for (let mi = 0; mi < MARKER_START.length && match; mi++) {
802
+ const p = finalGrid[i + mi];
803
+ if (!p ||
804
+ p.r !== MARKER_START[mi].r ||
805
+ p.g !== MARKER_START[mi].g ||
806
+ p.b !== MARKER_START[mi].b) {
807
+ match = false;
808
+ }
809
+ }
810
+ if (match) {
811
+ startIdx = i;
812
+ break;
813
+ }
814
+ }
815
+ if (startIdx === -1) {
816
+ if (process.env.ROX_DEBUG) {
817
+ console.log('DEBUG: MARKER_START not found in grid of', finalGrid.length, 'pixels');
818
+ console.log('DEBUG: Trying 2D scan for START marker...');
819
+ }
820
+ let found2D = false;
821
+ for (let y = 0; y < logicalHeight && !found2D; y++) {
822
+ for (let x = 0; x <= logicalWidth - MARKER_START.length && !found2D; x++) {
823
+ let match = true;
824
+ for (let mi = 0; mi < MARKER_START.length && match; mi++) {
825
+ const idx = (y * logicalWidth + (x + mi)) * 3;
826
+ if (idx + 2 >= logicalData.length ||
827
+ logicalData[idx] !== MARKER_START[mi].r ||
828
+ logicalData[idx + 1] !== MARKER_START[mi].g ||
829
+ logicalData[idx + 2] !== MARKER_START[mi].b) {
830
+ match = false;
831
+ }
832
+ }
833
+ if (match) {
834
+ if (process.env.ROX_DEBUG) {
835
+ console.log(`DEBUG: Found START marker in 2D at (${x}, ${y})`);
836
+ }
837
+ let endX = x + MARKER_START.length - 1;
838
+ let endY = y;
839
+ for (let scanY = y; scanY < logicalHeight; scanY++) {
840
+ let rowHasData = false;
841
+ for (let scanX = x; scanX < logicalWidth; scanX++) {
842
+ const scanIdx = (scanY * logicalWidth + scanX) * 3;
843
+ if (scanIdx + 2 < logicalData.length) {
844
+ const r = logicalData[scanIdx];
845
+ const g = logicalData[scanIdx + 1];
846
+ const b = logicalData[scanIdx + 2];
847
+ const isBackground = (r === 100 && g === 120 && b === 110) ||
848
+ (r === 0 && g === 0 && b === 0) ||
849
+ (r >= 50 &&
850
+ r <= 220 &&
851
+ g >= 50 &&
852
+ g <= 220 &&
853
+ b >= 50 &&
854
+ b <= 220 &&
855
+ Math.abs(r - g) < 70 &&
856
+ Math.abs(r - b) < 70 &&
857
+ Math.abs(g - b) < 70);
858
+ if (!isBackground) {
859
+ rowHasData = true;
860
+ if (scanX > endX) {
861
+ endX = scanX;
862
+ }
863
+ }
864
+ }
865
+ }
866
+ if (rowHasData) {
867
+ endY = scanY;
868
+ }
869
+ else if (scanY > y) {
870
+ break;
871
+ }
872
+ }
873
+ const rectWidth = endX - x + 1;
874
+ const rectHeight = endY - y + 1;
875
+ if (process.env.ROX_DEBUG) {
876
+ console.log(`DEBUG: Extracted rectangle: ${rectWidth}x${rectHeight} from (${x},${y})`);
877
+ }
878
+ finalGrid.length = 0;
879
+ for (let ry = y; ry <= endY; ry++) {
880
+ for (let rx = x; rx <= endX; rx++) {
881
+ const idx = (ry * logicalWidth + rx) * 3;
882
+ finalGrid.push({
883
+ r: logicalData[idx],
884
+ g: logicalData[idx + 1],
885
+ b: logicalData[idx + 2],
886
+ });
887
+ }
888
+ }
889
+ startIdx = 0;
890
+ found2D = true;
891
+ }
892
+ }
893
+ }
894
+ if (!found2D) {
895
+ if (process.env.ROX_DEBUG) {
896
+ console.log('DEBUG: First 20 pixels:', finalGrid
897
+ .slice(0, 20)
898
+ .map((p) => `(${p.r},${p.g},${p.b})`)
899
+ .join(' '));
900
+ }
901
+ throw new Error('Marker START not found - image format not supported');
902
+ }
903
+ }
904
+ if (process.env.ROX_DEBUG && startIdx === 0) {
905
+ console.log(`DEBUG: MARKER_START at index ${startIdx}, grid size: ${finalGrid.length}`);
906
+ }
907
+ const gridFromStart = finalGrid.slice(startIdx);
908
+ if (gridFromStart.length < MARKER_START.length + MARKER_END.length) {
909
+ if (process.env.ROX_DEBUG) {
910
+ console.log('DEBUG: gridFromStart too small:', gridFromStart.length, 'pixels');
911
+ }
912
+ throw new Error('Marker START or END not found - image format not supported');
913
+ }
914
+ for (let i = 0; i < MARKER_START.length; i++) {
915
+ if (gridFromStart[i].r !== MARKER_START[i].r ||
916
+ gridFromStart[i].g !== MARKER_START[i].g ||
917
+ gridFromStart[i].b !== MARKER_START[i].b) {
918
+ throw new Error('Marker START not found - image format not supported');
919
+ }
920
+ }
921
+ let endStartIdx = -1;
922
+ const lastLineStart = (logicalHeight - 1) * logicalWidth;
923
+ const endMarkerStartCol = logicalWidth - MARKER_END.length;
924
+ if (lastLineStart + endMarkerStartCol < finalGrid.length) {
925
+ let matchEnd = true;
926
+ for (let mi = 0; mi < MARKER_END.length && matchEnd; mi++) {
927
+ const idx = lastLineStart + endMarkerStartCol + mi;
928
+ if (idx >= finalGrid.length) {
929
+ matchEnd = false;
930
+ break;
931
+ }
932
+ const p = finalGrid[idx];
933
+ if (p.r !== MARKER_END[mi].r ||
934
+ p.g !== MARKER_END[mi].g ||
935
+ p.b !== MARKER_END[mi].b) {
936
+ matchEnd = false;
937
+ }
938
+ }
939
+ if (matchEnd) {
940
+ endStartIdx = lastLineStart + endMarkerStartCol - startIdx;
941
+ if (process.env.ROX_DEBUG) {
942
+ console.log(`DEBUG: Found END marker at last line, col ${endMarkerStartCol}`);
943
+ }
944
+ }
945
+ }
946
+ if (endStartIdx === -1) {
947
+ if (process.env.ROX_DEBUG) {
948
+ console.log('DEBUG: END marker not found at expected position');
949
+ console.log('DEBUG: Last line pixels:', finalGrid
950
+ .slice(Math.max(0, lastLineStart), finalGrid.length)
951
+ .map((p) => `(${p.r},${p.g},${p.b})`)
952
+ .join(' '));
953
+ }
954
+ endStartIdx = gridFromStart.length;
955
+ }
956
+ const dataGrid = gridFromStart.slice(MARKER_START.length, endStartIdx);
957
+ const pixelBytes = Buffer.alloc(dataGrid.length * 3);
958
+ for (let i = 0; i < dataGrid.length; i++) {
959
+ pixelBytes[i * 3] = dataGrid[i].r;
960
+ pixelBytes[i * 3 + 1] = dataGrid[i].g;
961
+ pixelBytes[i * 3 + 2] = dataGrid[i].b;
962
+ }
963
+ if (process.env.ROX_DEBUG) {
964
+ console.log('DEBUG: extracted len', pixelBytes.length);
965
+ console.log('DEBUG: extracted head', pixelBytes.slice(0, 32).toString('hex'));
966
+ const found = pixelBytes.indexOf(PIXEL_MAGIC);
967
+ console.log('DEBUG: PIXEL_MAGIC index:', found);
968
+ if (found !== -1) {
969
+ console.log('DEBUG: PIXEL_MAGIC head:', pixelBytes.slice(found, found + 64).toString('hex'));
970
+ const markerEndBytes = colorsToBytes(MARKER_END);
971
+ console.log('DEBUG: MARKER_END index:', pixelBytes.indexOf(markerEndBytes));
972
+ }
973
+ }
974
+ try {
975
+ let idx = 0;
976
+ if (pixelBytes.length >= PIXEL_MAGIC.length) {
977
+ const at0 = pixelBytes.slice(0, PIXEL_MAGIC.length).equals(PIXEL_MAGIC);
978
+ if (at0) {
979
+ idx = PIXEL_MAGIC.length;
980
+ }
981
+ else {
982
+ const found = pixelBytes.indexOf(PIXEL_MAGIC);
983
+ if (found !== -1) {
984
+ idx = found + PIXEL_MAGIC.length;
985
+ }
986
+ }
987
+ }
988
+ if (idx > 0) {
989
+ const version = pixelBytes[idx++];
990
+ const nameLen = pixelBytes[idx++];
991
+ let name;
992
+ if (nameLen > 0 && nameLen < 256) {
993
+ name = pixelBytes.slice(idx, idx + nameLen).toString('utf8');
994
+ idx += nameLen;
995
+ }
996
+ const payloadLen = pixelBytes.readUInt32BE(idx);
997
+ idx += 4;
998
+ const available = pixelBytes.length - idx;
999
+ if (available < payloadLen) {
1000
+ throw new DataFormatError(`Pixel payload truncated: expected ${payloadLen} bytes but only ${available} available`);
1001
+ }
1002
+ const rawPayload = pixelBytes.slice(idx, idx + payloadLen);
1003
+ let payload = tryDecryptIfNeeded(rawPayload, opts.passphrase);
1004
+ try {
1005
+ payload = tryBrotliDecompress(payload);
1006
+ }
1007
+ catch (e) {
1008
+ const errMsg = e instanceof Error ? e.message : String(e);
1009
+ if (opts.passphrase)
1010
+ throw new IncorrectPassphraseError('Incorrect passphrase (pixel mode, brotli failed: ' +
1011
+ errMsg +
1012
+ ')');
1013
+ throw new DataFormatError('Pixel mode brotli decompression failed: ' + errMsg);
1014
+ }
1015
+ if (!payload.slice(0, MAGIC.length).equals(MAGIC)) {
1016
+ throw new DataFormatError('Invalid ROX format (pixel mode: missing ROX1 magic after decompression)');
1017
+ }
1018
+ payload = payload.slice(MAGIC.length);
1019
+ return { buf: payload, meta: { name } };
1020
+ }
1021
+ }
1022
+ catch (e) {
1023
+ if (e instanceof PassphraseRequiredError ||
1024
+ e instanceof IncorrectPassphraseError ||
1025
+ e instanceof DataFormatError) {
1026
+ throw e;
1027
+ }
1028
+ const errMsg = e instanceof Error ? e.message : String(e);
1029
+ throw new Error('Failed to extract data from screenshot: ' + errMsg);
1030
+ }
1031
+ }
1032
+ catch (e) {
1033
+ if (e instanceof PassphraseRequiredError ||
1034
+ e instanceof IncorrectPassphraseError ||
1035
+ e instanceof DataFormatError) {
1036
+ throw e;
1037
+ }
1038
+ const errMsg = e instanceof Error ? e.message : String(e);
1039
+ throw new Error('Failed to decode PNG: ' + errMsg);
1040
+ }
1041
+ throw new DataFormatError('No valid data found in image');
1042
+ }