whalibmob 5.10.8 → 5.10.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1791,9 +1791,11 @@ Photos and videos are sent with an inline preview so they show up in the chat
1791
1791
  directly, rather than as a placeholder the recipient has to tap. The dimensions
1792
1792
  are read out of the file header with no dependency at all. The preview itself
1793
1793
  uses `jimp` (installed automatically as an optional dependency) or `sharp` if
1794
- you have it; failing both, a photo that carries an EXIF thumbnail still gets
1795
- one. Video previews need `ffmpeg` on PATH — on Termux, `pkg install ffmpeg`.
1796
- Without any of these the media still sends, just without the preview.
1794
+ you have it; failing both, a photo that carries an EXIF thumbnail gets one for
1795
+ free, and anything else is scaled in process — JPEG, PNG, GIF and BMP all work
1796
+ with nothing installed. Video previews need `ffmpeg` on PATH — on Termux,
1797
+ `pkg install ffmpeg`. Without any of these the media still sends, just without
1798
+ the preview.
1797
1799
 
1798
1800
  ### Image Message
1799
1801
 
@@ -2180,8 +2182,25 @@ rather than refused — so an unprepared file fails as a timeout that looks like
2180
2182
  network fault. The image is cropped square, scaled and written out as a fresh
2181
2183
  JPEG, which also strips EXIF and any progressive encoding the file was carrying.
2182
2184
 
2183
- This needs `jimp` (an optional dependency) or `sharp`. Without either, the file
2184
- is sent as it is and an already-correct picture still works.
2185
+ **No image library is required.** JPEG, PNG, GIF and BMP are decoded, cropped
2186
+ and re-encoded in process, so a bare `npm install` on a phone or a container
2187
+ built without a compiler converts a picture just as well as a full desktop.
2188
+ Whatever is installed is preferred, and the fallbacks run in this order:
2189
+
2190
+ | | Handles | Needs |
2191
+ |---|---|---|
2192
+ | `sharp` or `jimp` | everything, best quality | either installed |
2193
+ | built in | JPEG, PNG, GIF, BMP | nothing |
2194
+ | `ffmpeg` | WebP, HEIC, progressive JPEG | `ffmpeg` on PATH |
2195
+ | metadata strip | an already-square JPEG | nothing |
2196
+
2197
+ The built-in converter also reads the EXIF orientation tag, so a photo taken in
2198
+ portrait is turned upright instead of arriving on its side, and flattens
2199
+ transparency onto white, since a JPEG has no alpha channel.
2200
+
2201
+ Only an exotic format with nothing installed — a WebP or a HEIC on a machine
2202
+ with no `ffmpeg` — still fails, and the error says so rather than reporting a
2203
+ bare `406`.
2185
2204
 
2186
2205
  ```js
2187
2206
  // skip the re-encode if you have prepared the image yourself
@@ -3133,11 +3152,16 @@ about updated
3133
3152
 
3134
3153
  #### CLI Change Profile Picture
3135
3154
 
3136
- Reads the image from disk and uploads it as your profile picture. Supported formats: JPEG, PNG.
3155
+ Reads the image from disk, crops it square, scales it to 640×640 and uploads it
3156
+ as your profile picture. JPEG, PNG, GIF and BMP need nothing installed; WebP and
3157
+ HEIC need `ffmpeg` or `jimp`.
3137
3158
 
3138
3159
  ```sh
3139
3160
  wa> /photo ./avatar.jpg
3140
- profile picture updated
3161
+ profile picture updated id=1753912045
3162
+
3163
+ wa> /photo remove
3164
+ profile picture removed
3141
3165
  ```
3142
3166
 
3143
3167
  #### CLI Change Privacy Settings
package/cli.js CHANGED
@@ -447,7 +447,7 @@ const HELP = `
447
447
  Profile
448
448
  /name <text> change display name
449
449
  /about <text> change own bio / about text
450
- /photo <file>|remove change or remove own profile picture (JPEG)
450
+ /photo <file>|remove change or remove own profile picture (any image)
451
451
  /privacy [<type> <value>] show or change privacy settings
452
452
  types: last_seen profile_picture status
453
453
  online read_receipts groups_add
package/lib/Client.js CHANGED
@@ -10,7 +10,7 @@ const { NoiseSocket } = require('./noise');
10
10
  const { MessageSender, generateMessageId, makeJid, buildOrGetAdvIdentity } = require('./messages/MessageSender');
11
11
  const { checkIfRegistered, checkNumberStatus, requestSmsCode, verifyCode, assertRegistrationKeys, fetchIosVersion, fetchWaVersion } = require('./Registration');
12
12
  const { getDeviceConfig } = require('./DeviceConfig');
13
- const { prepareProfilePicture } = require('./MediaThumbnail');
13
+ const { prepareProfilePicture, probeImageSize, canDecodeImage } = require('./MediaThumbnail');
14
14
  const { createNewStore, saveStore, loadStore, toSixParts, fromSixParts } = require('./Store');
15
15
  const { BinaryNode } = require('./BinaryNode');
16
16
  const { AppStateStore, COLLECTIONS } = require('./appstate/AppStateStore');
@@ -252,6 +252,38 @@ function describeNodeBriefly(node, depth) {
252
252
  return out + '/>';
253
253
  }
254
254
 
255
+ // Say why a picture was turned down, from what can be read off the file.
256
+ //
257
+ // "not acceptable" is all the server offers, so the useful part is what we can
258
+ // work out ourselves: what the image is, whether it is square, and whether
259
+ // anything was available to fix it before it went out.
260
+ function _pictureRejected(what, picture, original, opts) {
261
+ const size = picture ? probeImageSize(picture) : null;
262
+ const bits = ['the server would not take this image (406 not-acceptable)'];
263
+
264
+ if (size && size.width && size.height) {
265
+ bits.push('it went out as ' + size.width + 'x' + size.height +
266
+ (size.width === size.height ? '' : ', which is not square'));
267
+ }
268
+ const isJpeg = picture && picture[0] === 0xff && picture[1] === 0xd8;
269
+ if (picture && !isJpeg) bits.push('and it is not a JPEG');
270
+
271
+ if (opts && opts.raw) {
272
+ bits.push('this call passed { raw: true }, so the file was sent exactly as given');
273
+ } else if (!isJpeg && original && !canDecodeImage(original)) {
274
+ // WebP, HEIC and the like need an outside converter; everything ordinary
275
+ // is handled without one.
276
+ bits.push('this format could not be converted here — install ffmpeg, or ' +
277
+ 'save the picture as a JPEG or PNG first');
278
+ } else {
279
+ bits.push('the picture was cropped square and re-encoded before sending, ' +
280
+ 'so the file itself is unlikely to be the problem — the account may not ' +
281
+ 'be allowed to set one');
282
+ }
283
+
284
+ return what + ': ' + bits.join('. ') + '.';
285
+ }
286
+
255
287
  function getNodeContent(node) {
256
288
  if (!node) return null;
257
289
  if (Buffer.isBuffer(node.content)) return node.content;
@@ -4365,15 +4397,16 @@ class WhalibmobClient extends EventEmitter {
4365
4397
  'The server drops a picture it will not take rather than refusing it, so ' +
4366
4398
  'this usually means the image was not in the form it wants' +
4367
4399
  (opts.raw ? ' — this call passed { raw: true }, so nothing was re-encoded'
4368
- : '. Install jimp if it is not present, so the image can be ' +
4369
- 're-encoded before sending') + '.');
4400
+ : '') + '.');
4370
4401
  }
4371
4402
  if (resp.attrs && resp.attrs.type === 'error') {
4372
4403
  const errNode = findChild(resp, 'error');
4373
4404
  const code = errNode && errNode.attrs && errNode.attrs.code;
4405
+ if (String(code) === '406') {
4406
+ throw new Error(_pictureRejected('changeProfilePicture', picture, buf, opts));
4407
+ }
4374
4408
  throw new Error('changeProfilePicture: ' +
4375
- (String(code) === '406' ? 'the image was rejected (must be a JPEG)'
4376
- : String(code) === '401' ? 'not authorised to change this picture'
4409
+ (String(code) === '401' ? 'not authorised to change this picture'
4377
4410
  : 'server rejected the request' + (code ? ' (' + code + ')' : '')));
4378
4411
  }
4379
4412
 
@@ -5960,9 +5993,11 @@ class WhalibmobClient extends EventEmitter {
5960
5993
  if (resp.attrs && resp.attrs.type === 'error') {
5961
5994
  const errNode = findChild(resp, 'error');
5962
5995
  const code = errNode && errNode.attrs && errNode.attrs.code;
5996
+ if (String(code) === '406') {
5997
+ throw new Error(_pictureRejected('changeGroupPicture', picture, buf, opts));
5998
+ }
5963
5999
  throw new Error('changeGroupPicture: ' +
5964
- (String(code) === '406' ? 'the image was rejected (must be a JPEG)'
5965
- : String(code) === '403' ? 'you are not allowed to change this group picture'
6000
+ (String(code) === '403' ? 'you are not allowed to change this group picture'
5966
6001
  : 'server rejected the request' + (code ? ' (' + code + ')' : '')));
5967
6002
  }
5968
6003
  if (!buf) return 'remove';
@@ -15,7 +15,8 @@
15
15
  // GIF and BMP — with no library at all.
16
16
  // • The thumbnail is made with sharp or jimp when either is installed. When
17
17
  // neither is, a JPEG that carries an EXIF thumbnail (which is to say, most
18
- // photos taken on a phone) still yields one, again with no library.
18
+ // photos taken on a phone) yields one for free, and anything else goes
19
+ // through the decoder in lib/image — still with nothing installed.
19
20
  // • Video frames need ffmpeg. Without it the video still sends, just without
20
21
  // an inline preview.
21
22
  //
@@ -30,6 +31,8 @@ const path = require('path');
30
31
  const crypto = require('crypto');
31
32
  const { execFile } = require('child_process');
32
33
 
34
+ const { toSquareJpeg, toScaledJpeg, canDecode: canDecodeImage } = require('./image');
35
+
33
36
  // The preview is deliberately tiny; it is only ever shown blurred behind the
34
37
  // real image while that downloads.
35
38
  const THUMB_WIDTH = 32;
@@ -220,8 +223,8 @@ async function loadImageLib() {
220
223
  }
221
224
  } catch (_) {}
222
225
 
223
- _whaDbg('[DBG] THUMB no image library — install jimp for inline previews ' +
224
- 'on images an EXIF thumbnail cannot cover');
226
+ _whaDbg('[DBG] THUMB no image library — falling back to the built-in ' +
227
+ 'decoder, which reads JPEG, PNG, GIF and BMP');
225
228
  return _imageLib;
226
229
  }
227
230
 
@@ -277,16 +280,32 @@ async function imageThumbnail(buf, opts) {
277
280
  _whaDbg('[DBG] THUMB image library failed: ' + (err && err.message));
278
281
  }
279
282
 
280
- // No library, or it could not read this file — fall back to whatever the
281
- // camera already embedded.
283
+ // No library, or it could not read this file. The camera's own embedded
284
+ // preview is the cheapest thing left, and on a phone photo it is usually
285
+ // there — a few kilobytes already sized about right.
282
286
  const exif = extractExifThumbnail(buf);
283
287
  if (exif) {
284
288
  out.thumbnail = exif;
285
289
  _whaDbg('[DBG] THUMB using embedded EXIF thumbnail (' + exif.length + ' bytes)');
286
- } else {
287
- _whaDbg('[DBG] THUMB none available — the image will show as a download ' +
288
- 'placeholder; install jimp to fix');
290
+ return out;
291
+ }
292
+
293
+ // Otherwise scale it here. Slower than a library, but a preview is 32 pixels
294
+ // wide and this is the difference between the picture showing in the chat and
295
+ // a grey download placeholder.
296
+ try {
297
+ const built = toScaledJpeg(buf, width, quality);
298
+ if (built && built.length) {
299
+ out.thumbnail = built;
300
+ _whaDbg('[DBG] THUMB made in-process (' + built.length + ' bytes)');
301
+ return out;
302
+ }
303
+ } catch (err) {
304
+ _whaDbg('[DBG] THUMB built-in scaler failed: ' + (err && err.message));
289
305
  }
306
+
307
+ _whaDbg('[DBG] THUMB none available — the image will show as a download ' +
308
+ 'placeholder; install jimp to fix');
290
309
  return out;
291
310
  }
292
311
 
@@ -301,13 +320,22 @@ const AVATAR_QUALITY = 50;
301
320
  /**
302
321
  * Re-encode an image into the form a profile picture has to be in.
303
322
  *
304
- * Cropped square from the top-left of the shorter side, scaled to 640x640, and
305
- * written out as a fresh JPEG. Re-encoding is the point rather than a side
306
- * effect: it strips EXIF, progressive scans and unusual chroma subsampling —
307
- * anything the file happened to carry — and produces a plain baseline JPEG.
323
+ * Cropped square, scaled to 640x640, and written out as a fresh JPEG.
324
+ * Re-encoding is the point rather than a side effect: it strips EXIF,
325
+ * progressive scans and unusual chroma subsampling — anything the file happened
326
+ * to carry — and produces a plain baseline JPEG.
308
327
  *
309
- * Returns the original buffer untouched when no image library is installed,
310
- * which at least gives an already-correct picture a chance to go through.
328
+ * Four converters are tried in turn, so a picture goes through whatever the
329
+ * machine happens to have:
330
+ *
331
+ * 1. sharp or jimp, when one is installed — the best quality and the widest
332
+ * range of formats.
333
+ * 2. the built-in converter, which reads JPEG, PNG, GIF and BMP with no
334
+ * dependency at all. This is the one that runs on a bare install.
335
+ * 3. ffmpeg, which covers what the built-in one does not: WebP, HEIC,
336
+ * progressive JPEG.
337
+ * 4. failing all of that, the file with its metadata segments stripped, which
338
+ * at least gives an already-square JPEG a chance.
311
339
  */
312
340
  async function prepareProfilePicture(buf, opts) {
313
341
  opts = opts || {};
@@ -345,15 +373,130 @@ async function prepareProfilePicture(buf, opts) {
345
373
  return await img.getBufferAsync(Jimp.MIME_JPEG);
346
374
  }
347
375
  } catch (err) {
348
- _whaDbg('[DBG] AVATAR could not re-encode: ' + (err && err.message));
349
- return buf;
376
+ _whaDbg('[DBG] AVATAR image library failed: ' + (err && err.message));
377
+ }
378
+
379
+ // No image library, or it could not read this file. The built-in converter
380
+ // needs nothing installed and handles the formats a picture is usually in.
381
+ try {
382
+ const built = toSquareJpeg(buf, size, quality);
383
+ if (built && built.length) {
384
+ _whaDbg('[DBG] AVATAR converted in-process to ' + size + 'x' + size +
385
+ ' (' + buf.length + ' → ' + built.length + ' bytes)');
386
+ return built;
387
+ }
388
+ } catch (err) {
389
+ _whaDbg('[DBG] AVATAR built-in converter failed: ' + (err && err.message));
390
+ }
391
+
392
+ // A format the built-in decoder does not read — WebP, HEIC, a progressive
393
+ // JPEG. ffmpeg can do the same job and is already used by the video path, so
394
+ // try it before giving up.
395
+ const viaFfmpeg = await avatarViaFfmpeg(buf, size, quality);
396
+ if (viaFfmpeg) return viaFfmpeg;
397
+
398
+ // Nothing that can re-encode. Strip the metadata segments at least: EXIF and
399
+ // the other APPn blocks are the part of an ordinary camera JPEG the server
400
+ // most often will not take, and removing them needs no decoder — the image
401
+ // data is left exactly as it was.
402
+ const stripped = stripJpegMetadata(buf);
403
+ if (stripped !== buf) {
404
+ _whaDbg('[DBG] AVATAR no converter — sent with metadata stripped (' +
405
+ buf.length + ' → ' + stripped.length + ' bytes). Install jimp or ffmpeg ' +
406
+ 'to have the image resized properly.');
407
+ return stripped;
350
408
  }
351
409
 
352
- _whaDbg('[DBG] AVATAR no image library — sending the file as it is. ' +
353
- 'WhatsApp wants a square JPEG; install jimp to have that done for you.');
410
+ _whaDbg('[DBG] AVATAR nothing could read this file — sending it as it is. ' +
411
+ 'WhatsApp wants a square JPEG; JPEG, PNG, GIF and BMP are converted here ' +
412
+ 'on their own, and ffmpeg or jimp covers the rest.');
354
413
  return buf;
355
414
  }
356
415
 
416
+ /**
417
+ * Crop square and resize with ffmpeg, which needs no native npm module.
418
+ *
419
+ * Returns null when ffmpeg is not installed or cannot read the file, so the
420
+ * caller can carry on to the next fallback.
421
+ */
422
+ async function avatarViaFfmpeg(buf, size, quality) {
423
+ if (!(await hasFfmpeg())) return null;
424
+
425
+ const src = tmpFile('.img');
426
+ const dst = tmpFile('.jpg');
427
+ try {
428
+ fs.writeFileSync(src, buf);
429
+ // Crop to the shorter side, scale, and write baseline JPEG. -q:v runs 2..31
430
+ // worst-to-best in ffmpeg, the opposite way round to a percentage quality.
431
+ const q = Math.max(2, Math.min(31, Math.round(31 - (quality / 100) * 29)));
432
+ const ok = await run('ffmpeg', [
433
+ '-y', '-i', src,
434
+ '-vf', "crop='min(iw,ih)':'min(iw,ih)',scale=" + size + ':' + size,
435
+ '-q:v', String(q),
436
+ '-f', 'mjpeg',
437
+ dst
438
+ ]);
439
+ if (ok === null) return null;
440
+ const out = fs.readFileSync(dst);
441
+ if (!out.length || out[0] !== 0xff || out[1] !== 0xd8) return null;
442
+ _whaDbg('[DBG] AVATAR re-encoded with ffmpeg (' + out.length + ' bytes)');
443
+ return out;
444
+ } catch (err) {
445
+ _whaDbg('[DBG] AVATAR ffmpeg failed: ' + (err && err.message));
446
+ return null;
447
+ } finally {
448
+ try { fs.unlinkSync(src); } catch (_) {}
449
+ try { fs.unlinkSync(dst); } catch (_) {}
450
+ }
451
+ }
452
+
453
+ /**
454
+ * Drop every APPn segment from a JPEG, keeping the image itself byte for byte.
455
+ *
456
+ * EXIF, ICC profiles, thumbnails and camera maker notes all live in these, and
457
+ * a picture that carries them is the ordinary case a phone produces. Removing
458
+ * them takes no decoder — it is a walk over the segment headers — so it works
459
+ * where nothing else is installed.
460
+ *
461
+ * Returns the input unchanged when it is not a JPEG or has nothing to drop.
462
+ */
463
+ function stripJpegMetadata(buf) {
464
+ if (!Buffer.isBuffer(buf) || buf.length < 4) return buf;
465
+ if (buf[0] !== 0xff || buf[1] !== 0xd8) return buf; // not a JPEG
466
+
467
+ const keep = [buf.slice(0, 2)];
468
+ let i = 2, dropped = 0;
469
+
470
+ while (i + 4 <= buf.length) {
471
+ if (buf[i] !== 0xff) break; // out of step; leave the rest be
472
+ const marker = buf[i + 1];
473
+
474
+ // Start of scan — everything from here is entropy-coded image data.
475
+ if (marker === 0xda) { keep.push(buf.slice(i)); i = buf.length; break; }
476
+ // Standalone markers carry no length.
477
+ if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd9)) {
478
+ keep.push(buf.slice(i, i + 2)); i += 2; continue;
479
+ }
480
+
481
+ const len = buf.readUInt16BE(i + 2);
482
+ if (len < 2 || i + 2 + len > buf.length) break; // malformed; stop here
483
+ const seg = buf.slice(i, i + 2 + len);
484
+
485
+ // APP0 is the JFIF header, which is the one to keep; APP1..APPF and the
486
+ // comment segment are metadata.
487
+ const isAppN = marker >= 0xe1 && marker <= 0xef;
488
+ const isComment = marker === 0xfe;
489
+ if (isAppN || isComment) dropped += seg.length;
490
+ else keep.push(seg);
491
+
492
+ i += 2 + len;
493
+ }
494
+
495
+ if (!dropped) return buf;
496
+ if (i < buf.length) keep.push(buf.slice(i));
497
+ return Buffer.concat(keep);
498
+ }
499
+
357
500
  // ─── ffmpeg ──────────────────────────────────────────────────────────────────
358
501
 
359
502
  function run(cmd, args) {
@@ -450,6 +593,8 @@ module.exports = {
450
593
  extractExifThumbnail,
451
594
  imageThumbnail,
452
595
  prepareProfilePicture,
596
+ stripJpegMetadata,
597
+ canDecodeImage,
453
598
  videoThumbnail,
454
599
  hasFfmpeg,
455
600
  THUMB_WIDTH,
@@ -0,0 +1,94 @@
1
+ 'use strict';
2
+
3
+ // BMP → RGBA.
4
+ //
5
+ // Uncompressed only, which is all but every BMP in the wild: 1/4/8-bit through
6
+ // the palette, and 16/24/32-bit direct. Rows are padded to four bytes and are
7
+ // stored bottom-up unless the height is negative.
8
+
9
+ function isBmp(buf) {
10
+ return Buffer.isBuffer(buf) && buf.length > 26 && buf[0] === 0x42 && buf[1] === 0x4d;
11
+ }
12
+
13
+ function decodeBmp(buf) {
14
+ if (!isBmp(buf)) return null;
15
+
16
+ const dataOffset = buf.readUInt32LE(10);
17
+ const headerSize = buf.readUInt32LE(14);
18
+ if (headerSize < 12) return null;
19
+
20
+ let width, height, bpp, compression = 0, paletteCount = 0;
21
+ if (headerSize === 12) { // BITMAPCOREHEADER
22
+ width = buf.readInt16LE(18);
23
+ height = buf.readInt16LE(20);
24
+ bpp = buf.readUInt16LE(24);
25
+ } else {
26
+ width = buf.readInt32LE(18);
27
+ height = buf.readInt32LE(22);
28
+ bpp = buf.readUInt16LE(28);
29
+ compression = buf.readUInt32LE(30);
30
+ paletteCount = buf.readUInt32LE(46);
31
+ }
32
+
33
+ const bottomUp = height > 0;
34
+ height = Math.abs(height);
35
+ if (!width || !height || width < 0) return null;
36
+ if (compression !== 0 && compression !== 3) return null; // RLE, JPEG, PNG
37
+ if (![1, 4, 8, 16, 24, 32].includes(bpp)) return null;
38
+
39
+ // The palette sits between the header and the pixel data.
40
+ const paletteStart = 14 + headerSize;
41
+ const entrySize = headerSize === 12 ? 3 : 4;
42
+ if (!paletteCount && bpp <= 8) paletteCount = 1 << bpp;
43
+
44
+ const rowBytes = Math.floor((width * bpp + 31) / 32) * 4;
45
+ if (dataOffset + rowBytes * height > buf.length) return null;
46
+
47
+ const out = Buffer.alloc(width * height * 4, 0xff);
48
+
49
+ for (let y = 0; y < height; y++) {
50
+ const srcRow = dataOffset + (bottomUp ? (height - 1 - y) : y) * rowBytes;
51
+ for (let x = 0; x < width; x++) {
52
+ let r, g, b, a = 255;
53
+
54
+ if (bpp <= 8) {
55
+ const perByte = 8 / bpp;
56
+ const byte = buf[srcRow + Math.floor(x / perByte)];
57
+ const shift = 8 - bpp * ((x % perByte) + 1);
58
+ const idx = (byte >> shift) & ((1 << bpp) - 1);
59
+ const p = paletteStart + idx * entrySize;
60
+ if (idx >= paletteCount || p + 2 >= buf.length) { r = g = b = 0; }
61
+ else { b = buf[p]; g = buf[p + 1]; r = buf[p + 2]; }
62
+ } else if (bpp === 16) {
63
+ // 5-5-5 with the top bit unused, widened back out to eight bits each.
64
+ const v = buf.readUInt16LE(srcRow + x * 2);
65
+ r = ((v >> 10) & 31) * 255 / 31;
66
+ g = ((v >> 5) & 31) * 255 / 31;
67
+ b = (v & 31) * 255 / 31;
68
+ r = Math.round(r); g = Math.round(g); b = Math.round(b);
69
+ } else {
70
+ const p = srcRow + x * (bpp / 8);
71
+ b = buf[p]; g = buf[p + 1]; r = buf[p + 2];
72
+ if (bpp === 32) {
73
+ const alpha = buf[p + 3];
74
+ // A 32-bit BMP often leaves the fourth byte at zero rather than
75
+ // meaning fully transparent; treat an all-zero channel as opaque.
76
+ a = alpha;
77
+ }
78
+ }
79
+
80
+ const o = (y * width + x) * 4;
81
+ out[o] = r; out[o + 1] = g; out[o + 2] = b; out[o + 3] = a;
82
+ }
83
+ }
84
+
85
+ if (bpp === 32) {
86
+ let anyAlpha = false;
87
+ for (let i = 3; i < out.length; i += 4) if (out[i] !== 0) { anyAlpha = true; break; }
88
+ if (!anyAlpha) for (let i = 3; i < out.length; i += 4) out[i] = 255;
89
+ }
90
+
91
+ return { width, height, data: out };
92
+ }
93
+
94
+ module.exports = { isBmp, decodeBmp };
@@ -0,0 +1,186 @@
1
+ 'use strict';
2
+
3
+ // GIF → RGBA, first frame only.
4
+ //
5
+ // A profile picture is a still, so an animation is read down to the image it
6
+ // opens on. The pixels are LZW-coded with a code width that grows as the
7
+ // dictionary fills — about forty lines of it, and the rest is walking the block
8
+ // structure to find where the frame starts.
9
+
10
+ function isGif(buf) {
11
+ return Buffer.isBuffer(buf) && buf.length > 13 &&
12
+ buf.slice(0, 3).toString('latin1') === 'GIF';
13
+ }
14
+
15
+ function decodeGif(buf) {
16
+ if (!isGif(buf)) return null;
17
+
18
+ const screenWidth = buf.readUInt16LE(6);
19
+ const screenHeight = buf.readUInt16LE(8);
20
+ const packed = buf[10];
21
+ const hasGlobal = !!(packed & 0x80);
22
+ const globalSize = 2 << (packed & 7);
23
+
24
+ let pos = 13;
25
+ let globalPalette = null;
26
+ if (hasGlobal) {
27
+ globalPalette = buf.slice(pos, pos + globalSize * 3);
28
+ pos += globalSize * 3;
29
+ }
30
+
31
+ let transparentIndex = -1;
32
+
33
+ while (pos < buf.length) {
34
+ const block = buf[pos];
35
+
36
+ if (block === 0x21) { // extension
37
+ const label = buf[pos + 1];
38
+ pos += 2;
39
+ if (label === 0xf9 && buf[pos] >= 4) { // graphic control
40
+ if (buf[pos + 1] & 1) transparentIndex = buf[pos + 4];
41
+ }
42
+ pos = skipBlocks(buf, pos);
43
+ continue;
44
+ }
45
+
46
+ if (block === 0x2c) { // image descriptor
47
+ const left = buf.readUInt16LE(pos + 1);
48
+ const top = buf.readUInt16LE(pos + 3);
49
+ const width = buf.readUInt16LE(pos + 5);
50
+ const height = buf.readUInt16LE(pos + 7);
51
+ const flags = buf[pos + 9];
52
+ pos += 10;
53
+
54
+ let palette = globalPalette;
55
+ if (flags & 0x80) {
56
+ const localSize = 2 << (flags & 7);
57
+ palette = buf.slice(pos, pos + localSize * 3);
58
+ pos += localSize * 3;
59
+ }
60
+ if (!palette) return null;
61
+ const interlaced = !!(flags & 0x40);
62
+
63
+ const minCodeSize = buf[pos];
64
+ pos++;
65
+ const data = gatherBlocks(buf, pos);
66
+ if (!data) return null;
67
+
68
+ const indices = lzw(data, minCodeSize, width * height);
69
+ if (!indices) return null;
70
+
71
+ return paint(indices, width, height, left, top,
72
+ screenWidth || width, screenHeight || height,
73
+ palette, transparentIndex, interlaced);
74
+ }
75
+
76
+ if (block === 0x3b) break; // trailer
77
+ pos++; // anything unexpected: step on
78
+ }
79
+
80
+ return null;
81
+ }
82
+
83
+ // Sub-blocks are length-prefixed and end with a zero length.
84
+ function skipBlocks(buf, pos) {
85
+ while (pos < buf.length && buf[pos] !== 0) pos += buf[pos] + 1;
86
+ return pos + 1;
87
+ }
88
+
89
+ function gatherBlocks(buf, pos) {
90
+ const parts = [];
91
+ while (pos < buf.length && buf[pos] !== 0) {
92
+ const len = buf[pos];
93
+ if (pos + 1 + len > buf.length) return null;
94
+ parts.push(buf.slice(pos + 1, pos + 1 + len));
95
+ pos += len + 1;
96
+ }
97
+ return Buffer.concat(parts);
98
+ }
99
+
100
+ // Variable-width LZW. The dictionary starts with one entry per colour plus a
101
+ // clear code and an end code, and every new entry is a previous one with one
102
+ // more pixel on the end.
103
+ function lzw(data, minCodeSize, expected) {
104
+ if (minCodeSize < 2 || minCodeSize > 8) return null;
105
+
106
+ const clearCode = 1 << minCodeSize;
107
+ const endCode = clearCode + 1;
108
+ const out = new Uint8Array(expected);
109
+
110
+ let dict = [], codeSize = 0, next = 0;
111
+ const reset = () => {
112
+ dict = new Array(clearCode + 2);
113
+ for (let i = 0; i < clearCode; i++) dict[i] = [i];
114
+ codeSize = minCodeSize + 1;
115
+ next = clearCode + 2;
116
+ };
117
+ reset();
118
+
119
+ let bitPos = 0, written = 0, prev = null;
120
+ const totalBits = data.length * 8;
121
+
122
+ while (written < expected && bitPos + codeSize <= totalBits) {
123
+ let code = 0;
124
+ for (let i = 0; i < codeSize; i++) {
125
+ const b = bitPos + i;
126
+ code |= ((data[b >> 3] >> (b & 7)) & 1) << i; // codes are little-endian
127
+ }
128
+ bitPos += codeSize;
129
+
130
+ if (code === clearCode) { reset(); prev = null; continue; }
131
+ if (code === endCode) break;
132
+
133
+ let entry;
134
+ if (code < next && dict[code]) entry = dict[code];
135
+ else if (prev) entry = prev.concat(prev[0]); // the KwKwK case
136
+ else break;
137
+
138
+ for (let i = 0; i < entry.length && written < expected; i++) out[written++] = entry[i];
139
+
140
+ if (prev) {
141
+ dict[next++] = prev.concat(entry[0]);
142
+ if (next === (1 << codeSize) && codeSize < 12) codeSize++;
143
+ }
144
+ prev = entry;
145
+ }
146
+
147
+ return out;
148
+ }
149
+
150
+ function paint(indices, width, height, left, top, canvasW, canvasH,
151
+ palette, transparentIndex, interlaced) {
152
+ const out = Buffer.alloc(canvasW * canvasH * 4, 0);
153
+
154
+ // An interlaced frame arrives in four passes over every eighth, fourth and
155
+ // second row in turn.
156
+ const rowOf = interlaced ? interlacedRows(height) : null;
157
+
158
+ for (let y = 0; y < height; y++) {
159
+ const dy = (rowOf ? rowOf[y] : y) + top;
160
+ if (dy < 0 || dy >= canvasH) continue;
161
+ for (let x = 0; x < width; x++) {
162
+ const dx = x + left;
163
+ if (dx < 0 || dx >= canvasW) continue;
164
+ const idx = indices[y * width + x];
165
+ const o = (dy * canvasW + dx) * 4;
166
+ if (idx === transparentIndex) { out[o + 3] = 0; continue; }
167
+ const p = idx * 3;
168
+ out[o] = palette[p] || 0;
169
+ out[o + 1] = palette[p + 1] || 0;
170
+ out[o + 2] = palette[p + 2] || 0;
171
+ out[o + 3] = 255;
172
+ }
173
+ }
174
+
175
+ return { width: canvasW, height: canvasH, data: out };
176
+ }
177
+
178
+ function interlacedRows(height) {
179
+ const rows = [];
180
+ for (const [start, step] of [[0, 8], [4, 8], [2, 4], [1, 2]]) {
181
+ for (let y = start; y < height; y += step) rows.push(y);
182
+ }
183
+ return rows;
184
+ }
185
+
186
+ module.exports = { isGif, decodeGif };