roxify 1.2.4 → 1.2.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/dist/cli.js +44 -48
- package/dist/index.d.ts +11 -132
- package/dist/index.js +11 -2639
- package/dist/minpng.js +31 -24
- package/dist/pack.d.ts +11 -10
- package/dist/pack.js +104 -62
- package/dist/utils/constants.d.ts +38 -0
- package/dist/utils/constants.js +22 -0
- package/dist/utils/crc.d.ts +4 -0
- package/dist/utils/crc.js +29 -0
- package/dist/utils/decoder.d.ts +4 -0
- package/dist/utils/decoder.js +626 -0
- package/dist/utils/encoder.d.ts +4 -0
- package/dist/utils/encoder.js +305 -0
- package/dist/utils/errors.d.ts +9 -0
- package/dist/utils/errors.js +18 -0
- package/dist/utils/helpers.d.ts +11 -0
- package/dist/utils/helpers.js +76 -0
- package/dist/utils/inspection.d.ts +14 -0
- package/dist/utils/inspection.js +388 -0
- package/dist/utils/optimization.d.ts +3 -0
- package/dist/utils/optimization.js +636 -0
- package/dist/utils/reconstitution.d.ts +3 -0
- package/dist/utils/reconstitution.js +266 -0
- package/dist/utils/types.d.ts +41 -0
- package/dist/utils/types.js +1 -0
- package/dist/utils/zstd.d.ts +17 -0
- package/dist/utils/zstd.js +118 -0
- package/package.json +1 -1
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
import cliProgress from 'cli-progress';
|
|
2
|
+
import { readFileSync } from 'fs';
|
|
3
|
+
import extract from 'png-chunks-extract';
|
|
4
|
+
import sharp from 'sharp';
|
|
5
|
+
import { unpackBuffer } from '../pack.js';
|
|
6
|
+
import { CHUNK_TYPE, MAGIC, MARKER_END, MARKER_START, PIXEL_MAGIC, PNG_HEADER, } from './constants.js';
|
|
7
|
+
import { DataFormatError, IncorrectPassphraseError, PassphraseRequiredError, } from './errors.js';
|
|
8
|
+
import { colorsToBytes, deltaDecode, tryDecryptIfNeeded } from './helpers.js';
|
|
9
|
+
import { cropAndReconstitute } from './reconstitution.js';
|
|
10
|
+
import { parallelZstdDecompress, tryZstdDecompress } from './zstd.js';
|
|
11
|
+
async function tryDecompress(payload, onProgress) {
|
|
12
|
+
try {
|
|
13
|
+
return await parallelZstdDecompress(payload, onProgress);
|
|
14
|
+
}
|
|
15
|
+
catch (e) {
|
|
16
|
+
try {
|
|
17
|
+
const mod = await import('lzma-purejs');
|
|
18
|
+
const decompressFn = mod && (mod.decompress || (mod.LZMA && mod.LZMA.decompress));
|
|
19
|
+
if (!decompressFn)
|
|
20
|
+
throw new Error('No lzma decompress');
|
|
21
|
+
const dec = await new Promise((resolve, reject) => {
|
|
22
|
+
try {
|
|
23
|
+
decompressFn(Buffer.from(payload), (out) => resolve(out));
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
reject(err);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
const dBuf = Buffer.isBuffer(dec) ? dec : Buffer.from(dec);
|
|
30
|
+
return dBuf;
|
|
31
|
+
}
|
|
32
|
+
catch (e3) {
|
|
33
|
+
throw e;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export async function decodePngToBinary(input, opts = {}) {
|
|
38
|
+
let pngBuf;
|
|
39
|
+
if (Buffer.isBuffer(input)) {
|
|
40
|
+
pngBuf = input;
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
try {
|
|
44
|
+
const metadata = await sharp(input).metadata();
|
|
45
|
+
const rawBytesEstimate = (metadata.width || 0) * (metadata.height || 0) * 4;
|
|
46
|
+
const MAX_RAW_BYTES = 200 * 1024 * 1024;
|
|
47
|
+
if (rawBytesEstimate > MAX_RAW_BYTES) {
|
|
48
|
+
pngBuf = readFileSync(input);
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
pngBuf = readFileSync(input);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch (e) {
|
|
55
|
+
try {
|
|
56
|
+
pngBuf = readFileSync(input);
|
|
57
|
+
}
|
|
58
|
+
catch (e2) {
|
|
59
|
+
throw e;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
let progressBar = null;
|
|
64
|
+
if (opts.showProgress) {
|
|
65
|
+
progressBar = new cliProgress.SingleBar({
|
|
66
|
+
format: ' {bar} {percentage}% | {step} | {elapsed}s',
|
|
67
|
+
}, cliProgress.Presets.shades_classic);
|
|
68
|
+
progressBar.start(100, 0, { step: 'Starting', elapsed: '0' });
|
|
69
|
+
const startTime = Date.now();
|
|
70
|
+
if (!opts.onProgress) {
|
|
71
|
+
opts.onProgress = (info) => {
|
|
72
|
+
let pct = 0;
|
|
73
|
+
if (info.phase === 'start') {
|
|
74
|
+
pct = 10;
|
|
75
|
+
}
|
|
76
|
+
else if (info.phase === 'decompress') {
|
|
77
|
+
pct = 50;
|
|
78
|
+
}
|
|
79
|
+
else if (info.phase === 'done') {
|
|
80
|
+
pct = 100;
|
|
81
|
+
}
|
|
82
|
+
progressBar.update(Math.floor(pct), {
|
|
83
|
+
step: info.phase.replace('_', ' '),
|
|
84
|
+
elapsed: String(Math.floor((Date.now() - startTime) / 1000)),
|
|
85
|
+
});
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (opts.onProgress)
|
|
90
|
+
opts.onProgress({ phase: 'start' });
|
|
91
|
+
let processedBuf = pngBuf;
|
|
92
|
+
try {
|
|
93
|
+
const info = await sharp(pngBuf).metadata();
|
|
94
|
+
if (info.width && info.height) {
|
|
95
|
+
const MAX_RAW_BYTES = 150 * 1024 * 1024;
|
|
96
|
+
const rawBytesEstimate = info.width * info.height * 4;
|
|
97
|
+
if (rawBytesEstimate > MAX_RAW_BYTES) {
|
|
98
|
+
throw new DataFormatError(`Image too large to decode in-process (${Math.round(rawBytesEstimate / 1024 / 1024)} MB). Increase Node heap or use a smaller image/compact mode.`);
|
|
99
|
+
}
|
|
100
|
+
if (false) {
|
|
101
|
+
const doubledBuffer = await sharp(pngBuf)
|
|
102
|
+
.resize({
|
|
103
|
+
width: info.width * 2,
|
|
104
|
+
height: info.height * 2,
|
|
105
|
+
kernel: 'nearest',
|
|
106
|
+
})
|
|
107
|
+
.png()
|
|
108
|
+
.toBuffer();
|
|
109
|
+
processedBuf = await cropAndReconstitute(doubledBuffer, opts.debugDir);
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
processedBuf = pngBuf;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
catch (e) {
|
|
117
|
+
if (e instanceof DataFormatError)
|
|
118
|
+
throw e;
|
|
119
|
+
}
|
|
120
|
+
if (opts.onProgress)
|
|
121
|
+
opts.onProgress({ phase: 'processed' });
|
|
122
|
+
if (processedBuf.subarray(0, MAGIC.length).equals(MAGIC)) {
|
|
123
|
+
const d = processedBuf.subarray(MAGIC.length);
|
|
124
|
+
const nameLen = d[0];
|
|
125
|
+
let idx = 1;
|
|
126
|
+
let name;
|
|
127
|
+
if (nameLen > 0) {
|
|
128
|
+
name = d.subarray(idx, idx + nameLen).toString('utf8');
|
|
129
|
+
idx += nameLen;
|
|
130
|
+
}
|
|
131
|
+
const rawPayload = d.subarray(idx);
|
|
132
|
+
let payload = tryDecryptIfNeeded(rawPayload, opts.passphrase);
|
|
133
|
+
if (opts.onProgress)
|
|
134
|
+
opts.onProgress({ phase: 'decompress_start' });
|
|
135
|
+
try {
|
|
136
|
+
payload = await tryDecompress(payload, (info) => {
|
|
137
|
+
if (opts.onProgress)
|
|
138
|
+
opts.onProgress(info);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
catch (e) {
|
|
142
|
+
const errMsg = e instanceof Error ? e.message : String(e);
|
|
143
|
+
if (opts.passphrase)
|
|
144
|
+
throw new IncorrectPassphraseError('Incorrect passphrase (compact mode, zstd failed: ' + errMsg + ')');
|
|
145
|
+
throw new DataFormatError('Compact mode zstd decompression failed: ' + errMsg);
|
|
146
|
+
}
|
|
147
|
+
if (!payload.subarray(0, MAGIC.length).equals(MAGIC)) {
|
|
148
|
+
throw new Error('Invalid ROX format (ROX direct: missing ROX1 magic after decompression)');
|
|
149
|
+
}
|
|
150
|
+
payload = payload.subarray(MAGIC.length);
|
|
151
|
+
if (opts.onProgress)
|
|
152
|
+
opts.onProgress({ phase: 'done' });
|
|
153
|
+
progressBar?.stop();
|
|
154
|
+
return { buf: payload, meta: { name } };
|
|
155
|
+
}
|
|
156
|
+
let chunks = [];
|
|
157
|
+
try {
|
|
158
|
+
const chunksRaw = extract(processedBuf);
|
|
159
|
+
chunks = chunksRaw.map((c) => ({
|
|
160
|
+
name: c.name,
|
|
161
|
+
data: Buffer.isBuffer(c.data)
|
|
162
|
+
? c.data
|
|
163
|
+
: Buffer.from(c.data),
|
|
164
|
+
}));
|
|
165
|
+
}
|
|
166
|
+
catch (e) {
|
|
167
|
+
try {
|
|
168
|
+
const withHeader = Buffer.concat([PNG_HEADER, pngBuf]);
|
|
169
|
+
const chunksRaw = extract(withHeader);
|
|
170
|
+
chunks = chunksRaw.map((c) => ({
|
|
171
|
+
name: c.name,
|
|
172
|
+
data: Buffer.isBuffer(c.data)
|
|
173
|
+
? c.data
|
|
174
|
+
: Buffer.from(c.data),
|
|
175
|
+
}));
|
|
176
|
+
}
|
|
177
|
+
catch (e2) {
|
|
178
|
+
chunks = [];
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const target = chunks.find((c) => c.name === CHUNK_TYPE);
|
|
182
|
+
if (target) {
|
|
183
|
+
const d = target.data;
|
|
184
|
+
const nameLen = d[0];
|
|
185
|
+
let idx = 1;
|
|
186
|
+
let name;
|
|
187
|
+
if (nameLen > 0) {
|
|
188
|
+
name = d.slice(idx, idx + nameLen).toString('utf8');
|
|
189
|
+
idx += nameLen;
|
|
190
|
+
}
|
|
191
|
+
const rawPayload = d.slice(idx);
|
|
192
|
+
if (rawPayload.length === 0)
|
|
193
|
+
throw new DataFormatError('Compact mode payload empty');
|
|
194
|
+
let payload = tryDecryptIfNeeded(rawPayload, opts.passphrase);
|
|
195
|
+
if (opts.onProgress)
|
|
196
|
+
opts.onProgress({ phase: 'decompress_start' });
|
|
197
|
+
try {
|
|
198
|
+
payload = await tryZstdDecompress(payload, (info) => {
|
|
199
|
+
if (opts.onProgress)
|
|
200
|
+
opts.onProgress(info);
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
catch (e) {
|
|
204
|
+
const errMsg = e instanceof Error ? e.message : String(e);
|
|
205
|
+
if (opts.passphrase)
|
|
206
|
+
throw new IncorrectPassphraseError('Incorrect passphrase (compact mode, zstd failed: ' + errMsg + ')');
|
|
207
|
+
throw new DataFormatError('Compact mode zstd decompression failed: ' + errMsg);
|
|
208
|
+
}
|
|
209
|
+
if (!payload.slice(0, MAGIC.length).equals(MAGIC)) {
|
|
210
|
+
throw new DataFormatError('Invalid ROX format (compact mode: missing ROX1 magic after decompression)');
|
|
211
|
+
}
|
|
212
|
+
payload = payload.slice(MAGIC.length);
|
|
213
|
+
if (opts.files) {
|
|
214
|
+
const unpacked = unpackBuffer(payload, opts.files);
|
|
215
|
+
if (unpacked) {
|
|
216
|
+
if (opts.onProgress)
|
|
217
|
+
opts.onProgress({ phase: 'done' });
|
|
218
|
+
progressBar?.stop();
|
|
219
|
+
return { files: unpacked.files, meta: { name } };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (opts.onProgress)
|
|
223
|
+
opts.onProgress({ phase: 'done' });
|
|
224
|
+
progressBar?.stop();
|
|
225
|
+
return { buf: payload, meta: { name } };
|
|
226
|
+
}
|
|
227
|
+
try {
|
|
228
|
+
const { data, info } = await sharp(processedBuf)
|
|
229
|
+
.ensureAlpha()
|
|
230
|
+
.raw()
|
|
231
|
+
.toBuffer({ resolveWithObject: true });
|
|
232
|
+
const currentWidth = info.width;
|
|
233
|
+
const currentHeight = info.height;
|
|
234
|
+
const rawRGB = Buffer.alloc(currentWidth * currentHeight * 3);
|
|
235
|
+
for (let i = 0; i < currentWidth * currentHeight; i++) {
|
|
236
|
+
rawRGB[i * 3] = data[i * 4];
|
|
237
|
+
rawRGB[i * 3 + 1] = data[i * 4 + 1];
|
|
238
|
+
rawRGB[i * 3 + 2] = data[i * 4 + 2];
|
|
239
|
+
}
|
|
240
|
+
const firstPixels = [];
|
|
241
|
+
for (let i = 0; i < Math.min(MARKER_START.length, rawRGB.length / 3); i++) {
|
|
242
|
+
firstPixels.push({
|
|
243
|
+
r: rawRGB[i * 3],
|
|
244
|
+
g: rawRGB[i * 3 + 1],
|
|
245
|
+
b: rawRGB[i * 3 + 2],
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
let hasMarkerStart = false;
|
|
249
|
+
if (firstPixels.length === MARKER_START.length) {
|
|
250
|
+
hasMarkerStart = true;
|
|
251
|
+
for (let i = 0; i < MARKER_START.length; i++) {
|
|
252
|
+
if (firstPixels[i].r !== MARKER_START[i].r ||
|
|
253
|
+
firstPixels[i].g !== MARKER_START[i].g ||
|
|
254
|
+
firstPixels[i].b !== MARKER_START[i].b) {
|
|
255
|
+
hasMarkerStart = false;
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
let hasPixelMagic = false;
|
|
261
|
+
if (rawRGB.length >= 8 + PIXEL_MAGIC.length) {
|
|
262
|
+
const widthFromDim = rawRGB.readUInt32BE(0);
|
|
263
|
+
const heightFromDim = rawRGB.readUInt32BE(4);
|
|
264
|
+
if (widthFromDim === currentWidth &&
|
|
265
|
+
heightFromDim === currentHeight &&
|
|
266
|
+
rawRGB.slice(8, 8 + PIXEL_MAGIC.length).equals(PIXEL_MAGIC)) {
|
|
267
|
+
hasPixelMagic = true;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
let logicalWidth;
|
|
271
|
+
let logicalHeight;
|
|
272
|
+
let logicalData;
|
|
273
|
+
if (hasMarkerStart || hasPixelMagic) {
|
|
274
|
+
logicalWidth = currentWidth;
|
|
275
|
+
logicalHeight = currentHeight;
|
|
276
|
+
logicalData = rawRGB;
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
const reconstructed = await cropAndReconstitute(processedBuf, opts.debugDir);
|
|
280
|
+
const { data: rdata, info: rinfo } = await sharp(reconstructed)
|
|
281
|
+
.ensureAlpha()
|
|
282
|
+
.raw()
|
|
283
|
+
.toBuffer({ resolveWithObject: true });
|
|
284
|
+
logicalWidth = rinfo.width;
|
|
285
|
+
logicalHeight = rinfo.height;
|
|
286
|
+
logicalData = Buffer.alloc(rinfo.width * rinfo.height * 3);
|
|
287
|
+
for (let i = 0; i < rinfo.width * rinfo.height; i++) {
|
|
288
|
+
logicalData[i * 3] = rdata[i * 4];
|
|
289
|
+
logicalData[i * 3 + 1] = rdata[i * 4 + 1];
|
|
290
|
+
logicalData[i * 3 + 2] = rdata[i * 4 + 2];
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (process.env.ROX_DEBUG) {
|
|
294
|
+
console.log('DEBUG: Logical grid reconstructed:', logicalWidth, 'x', logicalHeight, '=', logicalWidth * logicalHeight, 'pixels');
|
|
295
|
+
}
|
|
296
|
+
const finalGrid = [];
|
|
297
|
+
for (let i = 0; i < logicalData.length; i += 3) {
|
|
298
|
+
finalGrid.push({
|
|
299
|
+
r: logicalData[i],
|
|
300
|
+
g: logicalData[i + 1],
|
|
301
|
+
b: logicalData[i + 2],
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
if (hasPixelMagic) {
|
|
305
|
+
if (logicalData.length < 8 + PIXEL_MAGIC.length) {
|
|
306
|
+
throw new DataFormatError('Pixel mode data too short');
|
|
307
|
+
}
|
|
308
|
+
let idx = 8 + PIXEL_MAGIC.length;
|
|
309
|
+
const version = logicalData[idx++];
|
|
310
|
+
const nameLen = logicalData[idx++];
|
|
311
|
+
let name;
|
|
312
|
+
if (nameLen > 0 && nameLen < 256) {
|
|
313
|
+
name = logicalData.slice(idx, idx + nameLen).toString('utf8');
|
|
314
|
+
idx += nameLen;
|
|
315
|
+
}
|
|
316
|
+
const payloadLen = logicalData.readUInt32BE(idx);
|
|
317
|
+
idx += 4;
|
|
318
|
+
const available = logicalData.length - idx;
|
|
319
|
+
if (available < payloadLen) {
|
|
320
|
+
throw new DataFormatError(`Pixel payload truncated: expected ${payloadLen} bytes but only ${available} available`);
|
|
321
|
+
}
|
|
322
|
+
const rawPayload = logicalData.slice(idx, idx + payloadLen);
|
|
323
|
+
let payload = tryDecryptIfNeeded(rawPayload, opts.passphrase);
|
|
324
|
+
try {
|
|
325
|
+
payload = await tryZstdDecompress(payload, (info) => {
|
|
326
|
+
if (opts.onProgress)
|
|
327
|
+
opts.onProgress(info);
|
|
328
|
+
});
|
|
329
|
+
if (version === 3) {
|
|
330
|
+
payload = deltaDecode(payload);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
catch (e) { }
|
|
334
|
+
if (!payload.slice(0, MAGIC.length).equals(MAGIC)) {
|
|
335
|
+
throw new DataFormatError('Invalid ROX format (pixel mode: missing ROX1 magic after decompression)');
|
|
336
|
+
}
|
|
337
|
+
payload = payload.slice(MAGIC.length);
|
|
338
|
+
return { buf: payload, meta: { name } };
|
|
339
|
+
}
|
|
340
|
+
let startIdx = -1;
|
|
341
|
+
for (let i = 0; i <= finalGrid.length - MARKER_START.length; i++) {
|
|
342
|
+
let match = true;
|
|
343
|
+
for (let mi = 0; mi < MARKER_START.length && match; mi++) {
|
|
344
|
+
const p = finalGrid[i + mi];
|
|
345
|
+
if (!p ||
|
|
346
|
+
p.r !== MARKER_START[mi].r ||
|
|
347
|
+
p.g !== MARKER_START[mi].g ||
|
|
348
|
+
p.b !== MARKER_START[mi].b) {
|
|
349
|
+
match = false;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
if (match) {
|
|
353
|
+
startIdx = i;
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (startIdx === -1) {
|
|
358
|
+
if (process.env.ROX_DEBUG) {
|
|
359
|
+
console.log('DEBUG: MARKER_START not found in grid of', finalGrid.length, 'pixels');
|
|
360
|
+
console.log('DEBUG: Trying 2D scan for START marker...');
|
|
361
|
+
}
|
|
362
|
+
let found2D = false;
|
|
363
|
+
for (let y = 0; y < logicalHeight && !found2D; y++) {
|
|
364
|
+
for (let x = 0; x <= logicalWidth - MARKER_START.length && !found2D; x++) {
|
|
365
|
+
let match = true;
|
|
366
|
+
for (let mi = 0; mi < MARKER_START.length && match; mi++) {
|
|
367
|
+
const idx = (y * logicalWidth + (x + mi)) * 3;
|
|
368
|
+
if (idx + 2 >= logicalData.length ||
|
|
369
|
+
logicalData[idx] !== MARKER_START[mi].r ||
|
|
370
|
+
logicalData[idx + 1] !== MARKER_START[mi].g ||
|
|
371
|
+
logicalData[idx + 2] !== MARKER_START[mi].b) {
|
|
372
|
+
match = false;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
if (match) {
|
|
376
|
+
if (process.env.ROX_DEBUG) {
|
|
377
|
+
console.log(`DEBUG: Found START marker in 2D at (${x}, ${y})`);
|
|
378
|
+
}
|
|
379
|
+
let endX = x + MARKER_START.length - 1;
|
|
380
|
+
let endY = y;
|
|
381
|
+
for (let scanY = y; scanY < logicalHeight; scanY++) {
|
|
382
|
+
let rowHasData = false;
|
|
383
|
+
for (let scanX = x; scanX < logicalWidth; scanX++) {
|
|
384
|
+
const scanIdx = (scanY * logicalWidth + scanX) * 3;
|
|
385
|
+
if (scanIdx + 2 < logicalData.length) {
|
|
386
|
+
const r = logicalData[scanIdx];
|
|
387
|
+
const g = logicalData[scanIdx + 1];
|
|
388
|
+
const b = logicalData[scanIdx + 2];
|
|
389
|
+
const isBackground = (r === 100 && g === 120 && b === 110) ||
|
|
390
|
+
(r === 0 && g === 0 && b === 0) ||
|
|
391
|
+
(r >= 50 &&
|
|
392
|
+
r <= 220 &&
|
|
393
|
+
g >= 50 &&
|
|
394
|
+
g <= 220 &&
|
|
395
|
+
b >= 50 &&
|
|
396
|
+
b <= 220 &&
|
|
397
|
+
Math.abs(r - g) < 70 &&
|
|
398
|
+
Math.abs(r - b) < 70 &&
|
|
399
|
+
Math.abs(g - b) < 70);
|
|
400
|
+
if (!isBackground) {
|
|
401
|
+
rowHasData = true;
|
|
402
|
+
if (scanX > endX) {
|
|
403
|
+
endX = scanX;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
if (rowHasData) {
|
|
409
|
+
endY = scanY;
|
|
410
|
+
}
|
|
411
|
+
else if (scanY > y) {
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
const rectWidth = endX - x + 1;
|
|
416
|
+
const rectHeight = endY - y + 1;
|
|
417
|
+
if (process.env.ROX_DEBUG) {
|
|
418
|
+
console.log(`DEBUG: Extracted rectangle: ${rectWidth}x${rectHeight} from (${x},${y})`);
|
|
419
|
+
}
|
|
420
|
+
finalGrid.length = 0;
|
|
421
|
+
for (let ry = y; ry <= endY; ry++) {
|
|
422
|
+
for (let rx = x; rx <= endX; rx++) {
|
|
423
|
+
const idx = (ry * logicalWidth + rx) * 3;
|
|
424
|
+
finalGrid.push({
|
|
425
|
+
r: logicalData[idx],
|
|
426
|
+
g: logicalData[idx + 1],
|
|
427
|
+
b: logicalData[idx + 2],
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
startIdx = 0;
|
|
432
|
+
found2D = true;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
if (!found2D) {
|
|
437
|
+
if (process.env.ROX_DEBUG) {
|
|
438
|
+
console.log('DEBUG: First 20 pixels:', finalGrid
|
|
439
|
+
.slice(0, 20)
|
|
440
|
+
.map((p) => `(${p.r},${p.g},${p.b})`)
|
|
441
|
+
.join(' '));
|
|
442
|
+
}
|
|
443
|
+
throw new Error('Marker START not found - image format not supported');
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
if (process.env.ROX_DEBUG && startIdx === 0) {
|
|
447
|
+
console.log(`DEBUG: MARKER_START at index ${startIdx}, grid size: ${finalGrid.length}`);
|
|
448
|
+
}
|
|
449
|
+
const gridFromStart = finalGrid.slice(startIdx);
|
|
450
|
+
if (gridFromStart.length < MARKER_START.length + MARKER_END.length) {
|
|
451
|
+
if (process.env.ROX_DEBUG) {
|
|
452
|
+
console.log('DEBUG: gridFromStart too small:', gridFromStart.length, 'pixels');
|
|
453
|
+
}
|
|
454
|
+
throw new Error('Marker START or END not found - image format not supported');
|
|
455
|
+
}
|
|
456
|
+
for (let i = 0; i < MARKER_START.length; i++) {
|
|
457
|
+
if (gridFromStart[i].r !== MARKER_START[i].r ||
|
|
458
|
+
gridFromStart[i].g !== MARKER_START[i].g ||
|
|
459
|
+
gridFromStart[i].b !== MARKER_START[i].b) {
|
|
460
|
+
throw new Error('Marker START not found - image format not supported');
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
let compression = 'zstd';
|
|
464
|
+
if (gridFromStart.length > MARKER_START.length) {
|
|
465
|
+
const compPixel = gridFromStart[MARKER_START.length];
|
|
466
|
+
if (compPixel.r === 0 && compPixel.g === 255 && compPixel.b === 0) {
|
|
467
|
+
compression = 'zstd';
|
|
468
|
+
}
|
|
469
|
+
else {
|
|
470
|
+
compression = 'zstd';
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
if (process.env.ROX_DEBUG) {
|
|
474
|
+
console.log(`DEBUG: Detected compression: ${compression}`);
|
|
475
|
+
}
|
|
476
|
+
let endStartIdx = -1;
|
|
477
|
+
const lastLineStart = (logicalHeight - 1) * logicalWidth;
|
|
478
|
+
const endMarkerStartCol = logicalWidth - MARKER_END.length;
|
|
479
|
+
if (lastLineStart + endMarkerStartCol < finalGrid.length) {
|
|
480
|
+
let matchEnd = true;
|
|
481
|
+
for (let mi = 0; mi < MARKER_END.length && matchEnd; mi++) {
|
|
482
|
+
const idx = lastLineStart + endMarkerStartCol + mi;
|
|
483
|
+
if (idx >= finalGrid.length) {
|
|
484
|
+
matchEnd = false;
|
|
485
|
+
break;
|
|
486
|
+
}
|
|
487
|
+
const p = finalGrid[idx];
|
|
488
|
+
if (p.r !== MARKER_END[mi].r ||
|
|
489
|
+
p.g !== MARKER_END[mi].g ||
|
|
490
|
+
p.b !== MARKER_END[mi].b) {
|
|
491
|
+
matchEnd = false;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
if (matchEnd) {
|
|
495
|
+
endStartIdx = lastLineStart + endMarkerStartCol - startIdx;
|
|
496
|
+
if (process.env.ROX_DEBUG) {
|
|
497
|
+
console.log(`DEBUG: Found END marker at last line, col ${endMarkerStartCol}`);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
if (endStartIdx === -1) {
|
|
502
|
+
if (process.env.ROX_DEBUG) {
|
|
503
|
+
console.log('DEBUG: END marker not found at expected position');
|
|
504
|
+
console.log('DEBUG: Last line pixels:', finalGrid
|
|
505
|
+
.slice(Math.max(0, lastLineStart), finalGrid.length)
|
|
506
|
+
.map((p) => `(${p.r},${p.g},${p.b})`)
|
|
507
|
+
.join(' '));
|
|
508
|
+
}
|
|
509
|
+
endStartIdx = gridFromStart.length;
|
|
510
|
+
}
|
|
511
|
+
const dataGrid = gridFromStart.slice(MARKER_START.length + 1, endStartIdx);
|
|
512
|
+
const pixelBytes = Buffer.alloc(dataGrid.length * 3);
|
|
513
|
+
for (let i = 0; i < dataGrid.length; i++) {
|
|
514
|
+
pixelBytes[i * 3] = dataGrid[i].r;
|
|
515
|
+
pixelBytes[i * 3 + 1] = dataGrid[i].g;
|
|
516
|
+
pixelBytes[i * 3 + 2] = dataGrid[i].b;
|
|
517
|
+
}
|
|
518
|
+
if (process.env.ROX_DEBUG) {
|
|
519
|
+
console.log('DEBUG: extracted len', pixelBytes.length);
|
|
520
|
+
console.log('DEBUG: extracted head', pixelBytes.slice(0, 32).toString('hex'));
|
|
521
|
+
const found = pixelBytes.indexOf(PIXEL_MAGIC);
|
|
522
|
+
console.log('DEBUG: PIXEL_MAGIC index:', found);
|
|
523
|
+
if (found !== -1) {
|
|
524
|
+
console.log('DEBUG: PIXEL_MAGIC head:', pixelBytes.slice(found, found + 64).toString('hex'));
|
|
525
|
+
const markerEndBytes = colorsToBytes(MARKER_END);
|
|
526
|
+
console.log('DEBUG: MARKER_END index:', pixelBytes.indexOf(markerEndBytes));
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
try {
|
|
530
|
+
let idx = 0;
|
|
531
|
+
if (pixelBytes.length >= PIXEL_MAGIC.length) {
|
|
532
|
+
const at0 = pixelBytes.slice(0, PIXEL_MAGIC.length).equals(PIXEL_MAGIC);
|
|
533
|
+
if (at0) {
|
|
534
|
+
idx = PIXEL_MAGIC.length;
|
|
535
|
+
}
|
|
536
|
+
else {
|
|
537
|
+
const found = pixelBytes.indexOf(PIXEL_MAGIC);
|
|
538
|
+
if (found !== -1) {
|
|
539
|
+
idx = found + PIXEL_MAGIC.length;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
if (idx > 0) {
|
|
544
|
+
const version = pixelBytes[idx++];
|
|
545
|
+
const nameLen = pixelBytes[idx++];
|
|
546
|
+
let name;
|
|
547
|
+
if (nameLen > 0 && nameLen < 256) {
|
|
548
|
+
name = pixelBytes.slice(idx, idx + nameLen).toString('utf8');
|
|
549
|
+
idx += nameLen;
|
|
550
|
+
}
|
|
551
|
+
const payloadLen = pixelBytes.readUInt32BE(idx);
|
|
552
|
+
idx += 4;
|
|
553
|
+
if (idx + 4 <= pixelBytes.length) {
|
|
554
|
+
const marker = pixelBytes.slice(idx, idx + 4).toString('utf8');
|
|
555
|
+
if (marker === 'rXFL') {
|
|
556
|
+
idx += 4;
|
|
557
|
+
if (idx + 4 <= pixelBytes.length) {
|
|
558
|
+
const jsonLen = pixelBytes.readUInt32BE(idx);
|
|
559
|
+
idx += 4;
|
|
560
|
+
idx += jsonLen;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
const available = pixelBytes.length - idx;
|
|
565
|
+
if (available < payloadLen) {
|
|
566
|
+
throw new DataFormatError(`Pixel payload truncated: expected ${payloadLen} bytes but only ${available} available`);
|
|
567
|
+
}
|
|
568
|
+
const rawPayload = pixelBytes.slice(idx, idx + payloadLen);
|
|
569
|
+
let payload = tryDecryptIfNeeded(rawPayload, opts.passphrase);
|
|
570
|
+
try {
|
|
571
|
+
payload = await tryDecompress(payload, (info) => {
|
|
572
|
+
if (opts.onProgress)
|
|
573
|
+
opts.onProgress(info);
|
|
574
|
+
});
|
|
575
|
+
if (version === 3) {
|
|
576
|
+
payload = deltaDecode(payload);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
catch (e) {
|
|
580
|
+
const errMsg = e instanceof Error ? e.message : String(e);
|
|
581
|
+
if (opts.passphrase)
|
|
582
|
+
throw new IncorrectPassphraseError(`Incorrect passphrase (screenshot mode, zstd failed: ` +
|
|
583
|
+
errMsg +
|
|
584
|
+
')');
|
|
585
|
+
throw new DataFormatError(`Screenshot mode zstd decompression failed: ` + errMsg);
|
|
586
|
+
}
|
|
587
|
+
if (!payload.slice(0, MAGIC.length).equals(MAGIC)) {
|
|
588
|
+
throw new DataFormatError('Invalid ROX format (pixel mode: missing ROX1 magic after decompression)');
|
|
589
|
+
}
|
|
590
|
+
payload = payload.slice(MAGIC.length);
|
|
591
|
+
if (opts.files) {
|
|
592
|
+
const unpacked = unpackBuffer(payload, opts.files);
|
|
593
|
+
if (unpacked) {
|
|
594
|
+
if (opts.onProgress)
|
|
595
|
+
opts.onProgress({ phase: 'done' });
|
|
596
|
+
progressBar?.stop();
|
|
597
|
+
return { files: unpacked.files, meta: { name } };
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
if (opts.onProgress)
|
|
601
|
+
opts.onProgress({ phase: 'done' });
|
|
602
|
+
progressBar?.stop();
|
|
603
|
+
return { buf: payload, meta: { name } };
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
catch (e) {
|
|
607
|
+
if (e instanceof PassphraseRequiredError ||
|
|
608
|
+
e instanceof IncorrectPassphraseError ||
|
|
609
|
+
e instanceof DataFormatError) {
|
|
610
|
+
throw e;
|
|
611
|
+
}
|
|
612
|
+
const errMsg = e instanceof Error ? e.message : String(e);
|
|
613
|
+
throw new Error('Failed to extract data from screenshot: ' + errMsg);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
catch (e) {
|
|
617
|
+
if (e instanceof PassphraseRequiredError ||
|
|
618
|
+
e instanceof IncorrectPassphraseError ||
|
|
619
|
+
e instanceof DataFormatError) {
|
|
620
|
+
throw e;
|
|
621
|
+
}
|
|
622
|
+
const errMsg = e instanceof Error ? e.message : String(e);
|
|
623
|
+
throw new Error('Failed to decode PNG: ' + errMsg);
|
|
624
|
+
}
|
|
625
|
+
throw new DataFormatError('No valid data found in image');
|
|
626
|
+
}
|