summaryrefslogtreecommitdiffstats
path: root/roles/reverseproxy/files/conversejs/src/shared/gif
diff options
context:
space:
mode:
Diffstat (limited to 'roles/reverseproxy/files/conversejs/src/shared/gif')
-rw-r--r--roles/reverseproxy/files/conversejs/src/shared/gif/index.js569
-rw-r--r--roles/reverseproxy/files/conversejs/src/shared/gif/stream.js43
-rw-r--r--roles/reverseproxy/files/conversejs/src/shared/gif/utils.js319
3 files changed, 931 insertions, 0 deletions
diff --git a/roles/reverseproxy/files/conversejs/src/shared/gif/index.js b/roles/reverseproxy/files/conversejs/src/shared/gif/index.js
new file mode 100644
index 0000000..abfa05f
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/shared/gif/index.js
@@ -0,0 +1,569 @@
+/**
+ * @copyright Shachaf Ben-Kiki, JC Brand
+ * @description
+ * Started as a fork of Shachaf Ben-Kiki's jsgif library
+ * https://github.com/shachaf/jsgif
+ * @license MIT License
+ */
+import Stream from './stream.js';
+import { getOpenPromise } from '@converse/openpromise';
+import { parseGIF } from './utils.js';
+
+const DELAY_FACTOR = 10;
+
+
+export default class ConverseGif {
+
+ /**
+ * Creates a new ConverseGif instance
+ * @param { HTMLElement } el
+ * @param { Object } [options]
+ * @param { Number } [options.width] - The width, in pixels, of the canvas
+ * @param { Number } [options.height] - The height, in pixels, of the canvas
+ * @param { Boolean } [options.loop=true] - Setting this to `true` will enable looping of the gif
+ * @param { Boolean } [options.autoplay=true] - Same as the rel:autoplay attribute above, this arg overrides the img tag info.
+ * @param { Number } [options.max_width] - Scale images over max_width down to max_width. Helpful with mobile.
+ * @param { Function } [options.onIterationEnd] - Add a callback for when the gif reaches the end of a single loop (one iteration). The first argument passed will be the gif HTMLElement.
+ * @param { Boolean } [options.show_progress_bar=true]
+ * @param { String } [options.progress_bg_color='rgba(0,0,0,0.4)']
+ * @param { String } [options.progress_color='rgba(255,0,22,.8)']
+ * @param { Number } [options.progress_bar_height=5]
+ */
+ constructor (el, opts) {
+ this.options = Object.assign({
+ width: null,
+ height: null,
+ autoplay: true,
+ loop: true,
+ show_progress_bar: true,
+ progress_bg_color: 'rgba(0,0,0,0.4)',
+ progress_color: 'rgba(255,0,22,.8)',
+ progress_bar_height: 5
+ },
+ opts
+ );
+
+ this.el = el;
+ this.gif_el = el.querySelector('img');
+ this.canvas = el.querySelector('canvas');
+ this.ctx = this.canvas.getContext('2d');
+ // It's good practice to pre-render to an offscreen canvas
+ this.offscreenCanvas = document.createElement('canvas');
+
+ this.ctx_scaled = false;
+ this.disposal_method = null;
+ this.disposal_restore_from_idx = null;
+ this.frame = null;
+ this.frame_offsets = []; // elements have .x and .y properties
+ this.frames = [];
+ this.last_disposal_method = null;
+ this.last_img = null;
+ this.load_error = null;
+ this.playing = this.options.autoplay;
+ this.transparency = null;
+ this.frame_delay = null;
+
+ this.frame_idx = 0;
+ this.iteration_count = 0;
+ this.start = null;
+
+ this.initialize();
+ }
+
+ async initialize () {
+ if (this.options.width && this.options.height) {
+ this.setSizes(this.options.width, this.options.height);
+ }
+ const data = await this.fetchGIF(this.gif_el.src);
+ requestAnimationFrame(() => this.startParsing(data));
+ }
+
+ initPlayer () {
+ if (this.load_error) return;
+
+ if (!(this.options.width && this.options.height)) {
+ this.ctx.scale(this.getCanvasScale(), this.getCanvasScale());
+ }
+
+ // Show the first frame
+ this.frame_idx = 0;
+ this.putFrame(this.frame_idx);
+
+ if (this.options.autoplay) {
+ const delay = (this.frames[this.frame_idx]?.delay ?? 0) * DELAY_FACTOR;
+ setTimeout(() => this.play(), delay);
+ }
+ }
+
+ /**
+ * Gets the index of the frame "up next"
+ * @returns {number}
+ */
+ getNextFrameNo () {
+ if (this.frames.length === 0) {
+ return 0;
+ }
+ return (this.frame_idx + 1 + this.frames.length) % this.frames.length;
+ }
+
+ /**
+ * Called once we've looped through all frames in the GIF
+ * @returns { Boolean } - Returns `true` if the GIF is now paused (i.e. further iterations are not desired)
+ */
+ onIterationEnd () {
+ this.iteration_count++;
+ this.options.onIterationEnd?.(this);
+ if (!this.options.loop) {
+ this.pause();
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Inner callback for the `requestAnimationFrame` function.
+ *
+ * This method gets wrapped by an arrow function so that the `previous_timestamp` and
+ * `frame_delay` parameters can also be passed in. The `timestamp`
+ * parameter comes from `requestAnimationFrame`.
+ *
+ * The purpose of this method is to call `putFrame` with the right delay
+ * in order to render the GIF animation.
+ *
+ * Note, this method will cause the *next* upcoming frame to be rendered,
+ * not the current one.
+ *
+ * This means `this.frame_idx` will be incremented before calling `this.putFrame`, so
+ * `putFrame(0)` needs to be called *before* this method, otherwise the
+ * animation will incorrectly start from frame #1 (this is done in `initPlayer`).
+ *
+ * @param { DOMHighRestTimestamp } timestamp - The timestamp as returned by `requestAnimationFrame`
+ * @param { DOMHighRestTimestamp } previous_timestamp - The timestamp from the previous iteration of this method.
+ * We need this in order to calculate whether we have waited long enough to
+ * show the next frame.
+ * @param { Number } frame_delay - The delay (in 1/100th of a second)
+ * before the currently being shown frame should be replaced by a new one.
+ */
+ onAnimationFrame (timestamp, previous_timestamp, frame_delay) {
+ if (!this.playing) {
+ return;
+ }
+ if ((timestamp - previous_timestamp) < frame_delay) {
+ this.hovering ? this.drawPauseIcon() : this.putFrame(this.frame_idx);
+ // We need to wait longer
+ requestAnimationFrame(ts => this.onAnimationFrame(ts, previous_timestamp, frame_delay));
+ return;
+ }
+ const next_frame = this.getNextFrameNo();
+ if (next_frame === 0 && this.onIterationEnd()) {
+ return;
+ }
+ this.frame_idx = next_frame;
+ this.putFrame(this.frame_idx);
+ const delay = (this.frames[this.frame_idx]?.delay || 8) * DELAY_FACTOR;
+ requestAnimationFrame(ts => this.onAnimationFrame(ts, timestamp, delay));
+ }
+
+ setSizes (w, h) {
+ this.canvas.width = w * this.getCanvasScale();
+ this.canvas.height = h * this.getCanvasScale();
+
+ this.offscreenCanvas.width = w;
+ this.offscreenCanvas.height = h;
+ this.offscreenCanvas.style.width = w + 'px';
+ this.offscreenCanvas.style.height = h + 'px';
+ this.offscreenCanvas.getContext('2d').setTransform(1, 0, 0, 1, 0, 0);
+ }
+
+ setFrameOffset (frame, offset) {
+ if (!this.frame_offsets[frame]) {
+ this.frame_offsets[frame] = offset;
+ return;
+ }
+ if (typeof offset.x !== 'undefined') {
+ this.frame_offsets[frame].x = offset.x;
+ }
+ if (typeof offset.y !== 'undefined') {
+ this.frame_offsets[frame].y = offset.y;
+ }
+ }
+
+ doShowProgress (pos, length, draw) {
+ if (draw && this.options.show_progress_bar) {
+ let height = this.options.progress_bar_height;
+ const top = (this.canvas.height - height) / (this.ctx_scaled ? this.getCanvasScale() : 1);
+ const mid = ((pos / length) * this.canvas.width) / (this.ctx_scaled ? this.getCanvasScale() : 1);
+ const width = this.canvas.width / (this.ctx_scaled ? this.getCanvasScale() : 1);
+ height /= this.ctx_scaled ? this.getCanvasScale() : 1;
+
+ this.ctx.fillStyle = this.options.progress_bg_color;
+ this.ctx.fillRect(mid, top, width - mid, height);
+
+ this.ctx.fillStyle = this.options.progress_color;
+ this.ctx.fillRect(0, top, mid, height);
+ }
+ }
+
+ /**
+ * Starts parsing the GIF stream data by calling `parseGIF` and passing in
+ * a map of handler functions.
+ * @param { String } data - The GIF file data, as returned by the server
+ */
+ startParsing (data) {
+ const stream = new Stream(data);
+ /**
+ * @typedef { Object } GIFParserHandlers
+ * A map of callback functions passed `parseGIF`. These functions are
+ * called as various parts of the GIF file format are parsed.
+ * @property { Function } hdr - Callback to handle the GIF header data
+ * @property { Function } gce - Callback to handle the GIF Graphic Control Extension data
+ * @property { Function } com - Callback to handle the comment extension block
+ * @property { Function } img - Callback to handle image data
+ * @property { Function } eof - Callback once the end of file has been reached
+ */
+ const handler = {
+ 'hdr': this.withProgress(stream, header => this.handleHeader(header)),
+ 'gce': this.withProgress(stream, gce => this.handleGCE(gce)),
+ 'com': this.withProgress(stream, ),
+ 'img': this.withProgress(stream, img => this.doImg(img), true),
+ 'eof': () => this.handleEOF(stream)
+ };
+ try {
+ parseGIF(stream, handler);
+ } catch (err) {
+ this.showError('parse');
+ }
+ }
+
+ drawError () {
+ this.ctx.fillStyle = 'black';
+ this.ctx.fillRect(
+ 0,
+ 0,
+ this.options.width ? this.options.width : this.hdr.width,
+ this.options.height ? this.options.height : this.hdr.height
+ );
+ this.ctx.strokeStyle = 'red';
+ this.ctx.lineWidth = 3;
+ this.ctx.moveTo(0, 0);
+ this.ctx.lineTo(
+ this.options.width ? this.options.width : this.hdr.width,
+ this.options.height ? this.options.height : this.hdr.height
+ );
+ this.ctx.moveTo(0, this.options.height ? this.options.height : this.hdr.height);
+ this.ctx.lineTo(this.options.width ? this.options.width : this.hdr.width, 0);
+ this.ctx.stroke();
+ }
+
+ showError (errtype) {
+ this.load_error = errtype;
+ this.hdr = {
+ width: this.gif_el.width,
+ height: this.gif_el.height,
+ }; // Fake header.
+ this.frames = [];
+ this.drawError();
+ this.el.requestUpdate();
+ }
+
+ handleHeader (header) {
+ this.hdr = header;
+ this.setSizes(
+ this.options.width ?? this.hdr.width,
+ this.options.height ?? this.hdr.height
+ );
+ }
+
+ /**
+ * Handler for GIF Graphic Control Extension (GCE) data
+ */
+ handleGCE (gce) {
+ this.pushFrame();
+ this.clear();
+ this.frame_delay = gce.delayTime;
+ this.transparency = gce.transparencyGiven ? gce.transparencyIndex : null;
+ this.disposal_method = gce.disposalMethod;
+ }
+
+ /**
+ * Handler for when the end of the GIF's file has been reached
+ */
+ handleEOF (stream) {
+ this.pushFrame();
+ this.doDecodeProgress(stream, false);
+ this.initPlayer();
+ !this.options.autoplay && this.drawPlayIcon();
+ }
+
+ pushFrame () {
+ if (!this.frame) return;
+ this.frames.push({
+ data: this.frame.getImageData(0, 0, this.hdr.width, this.hdr.height),
+ delay: this.frame_delay
+ });
+ this.frame_offsets.push({ x: 0, y: 0 });
+ }
+
+ doImg (img) {
+ this.frame = this.frame || this.offscreenCanvas.getContext('2d');
+ const currIdx = this.frames.length;
+
+ //ct = color table, gct = global color table
+ const ct = img.lctFlag ? img.lct : this.hdr.gct; // TODO: What if neither exists?
+
+ /*
+ * Disposal method indicates the way in which the graphic is to
+ * be treated after being displayed.
+ *
+ * Values : 0 - No disposal specified. The decoder is
+ * not required to take any action.
+ * 1 - Do not dispose. The graphic is to be left
+ * in place.
+ * 2 - Restore to background color. The area used by the
+ * graphic must be restored to the background color.
+ * 3 - Restore to previous. The decoder is required to
+ * restore the area overwritten by the graphic with
+ * what was there prior to rendering the graphic.
+ *
+ * Importantly, "previous" means the frame state
+ * after the last disposal of method 0, 1, or 2.
+ */
+ if (currIdx > 0) {
+ if (this.last_disposal_method === 3) {
+ // Restore to previous
+ // If we disposed every frame including first frame up to this point, then we have
+ // no composited frame to restore to. In this case, restore to background instead.
+ if (this.disposal_restore_from_idx !== null) {
+ this.frame.putImageData(this.frames[this.disposal_restore_from_idx].data, 0, 0);
+ } else {
+ this.frame.clearRect(
+ this.last_img.leftPos,
+ this.last_img.topPos,
+ this.last_img.width,
+ this.last_img.height
+ );
+ }
+ } else {
+ this.disposal_restore_from_idx = currIdx - 1;
+ }
+
+ if (this.last_disposal_method === 2) {
+ // Restore to background color
+ // Browser implementations historically restore to transparent; we do the same.
+ // http://www.wizards-toolkit.org/discourse-server/viewtopic.php?f=1&t=21172#p86079
+ this.frame.clearRect(
+ this.last_img.leftPos,
+ this.last_img.topPos,
+ this.last_img.width,
+ this.last_img.height
+ );
+ }
+ }
+ // else, Undefined/Do not dispose.
+ // frame contains final pixel data from the last frame; do nothing
+
+ //Get existing pixels for img region after applying disposal method
+ const imgData = this.frame.getImageData(img.leftPos, img.topPos, img.width, img.height);
+
+ //apply color table colors
+ img.pixels.forEach((pixel, i) => {
+ // imgData.data === [R,G,B,A,R,G,B,A,...]
+ if (pixel !== this.transparency) {
+ imgData.data[i * 4 + 0] = ct[pixel][0];
+ imgData.data[i * 4 + 1] = ct[pixel][1];
+ imgData.data[i * 4 + 2] = ct[pixel][2];
+ imgData.data[i * 4 + 3] = 255; // Opaque.
+ }
+ });
+
+ this.frame.putImageData(imgData, img.leftPos, img.topPos);
+
+ if (!this.ctx_scaled) {
+ this.ctx.scale(this.getCanvasScale(), this.getCanvasScale());
+ this.ctx_scaled = true;
+ }
+
+ if (!this.last_img) {
+ // This is the first received image, so we draw it
+ this.ctx.drawImage(this.offscreenCanvas, 0, 0);
+ }
+ this.last_img = img;
+ }
+
+ /**
+ * Draws a gif frame at a specific index inside the canvas.
+ * @param { Number } i - The frame index
+ */
+ putFrame (i, show_pause_on_hover=true) {
+ if (!this.frames.length) return
+
+ i = parseInt(i, 10);
+ if (i > this.frames.length - 1 || i < 0) {
+ i = 0;
+ }
+ const offset = this.frame_offsets[i];
+ this.offscreenCanvas.getContext('2d').putImageData(this.frames[i].data, offset.x, offset.y);
+ this.ctx.globalCompositeOperation = 'copy';
+ this.ctx.drawImage(this.offscreenCanvas, 0, 0);
+
+ if (show_pause_on_hover && this.hovering) {
+ this.drawPauseIcon();
+ }
+ }
+
+ clear () {
+ this.transparency = null;
+ this.last_disposal_method = this.disposal_method;
+ this.disposal_method = null;
+ this.frame = null;
+ }
+
+ /**
+ * Start playing the gif
+ */
+ play () {
+ this.playing = true;
+ requestAnimationFrame(ts => this.onAnimationFrame(ts, 0, 0));
+ }
+
+ /**
+ * Pause the gif
+ */
+ pause () {
+ this.playing = false;
+ requestAnimationFrame(() => this.drawPlayIcon())
+ }
+
+ drawPauseIcon () {
+ if (!this.playing) {
+ return;
+ }
+ // Clear the potential play button by re-rendering the current frame
+ this.putFrame(this.frame_idx, false);
+
+ this.ctx.globalCompositeOperation = 'source-over';
+
+ // Draw dark overlay
+ this.ctx.fillStyle = 'rgb(0, 0, 0, 0.25)';
+ this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
+
+ const icon_size = this.canvas.height*0.1;
+
+ // Draw bars
+ this.ctx.lineWidth = this.canvas.height*0.04;
+ this.ctx.beginPath();
+ this.ctx.moveTo(this.canvas.width/2-icon_size/2, this.canvas.height/2-icon_size);
+ this.ctx.lineTo(this.canvas.width/2-icon_size/2, this.canvas.height/2+icon_size);
+ this.ctx.fillStyle = 'rgb(200, 200, 200, 0.75)';
+ this.ctx.stroke();
+
+ this.ctx.beginPath();
+ this.ctx.moveTo(this.canvas.width/2+icon_size/2, this.canvas.height/2-icon_size);
+ this.ctx.lineTo(this.canvas.width/2+icon_size/2, this.canvas.height/2+icon_size);
+ this.ctx.fillStyle = 'rgb(200, 200, 200, 0.75)';
+ this.ctx.stroke();
+
+ // Draw circle
+ this.ctx.lineWidth = this.canvas.height*0.02;
+ this.ctx.strokeStyle = 'rgb(200, 200, 200, 0.75)';
+ this.ctx.beginPath();
+ this.ctx.arc(
+ this.canvas.width/2,
+ this.canvas.height/2,
+ icon_size*1.5,
+ 0,
+ 2*Math.PI
+ );
+ this.ctx.stroke();
+ }
+
+ drawPlayIcon () {
+ if (this.playing) {
+ return;
+ }
+
+ // Clear the potential pause button by re-rendering the current frame
+ this.putFrame(this.frame_idx, false);
+
+ this.ctx.globalCompositeOperation = 'source-over';
+
+ // Draw dark overlay
+ this.ctx.fillStyle = 'rgb(0, 0, 0, 0.25)';
+ this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
+
+ // Draw triangle
+ const triangle_size = this.canvas.height*0.1;
+ const region = new Path2D();
+ region.moveTo(this.canvas.width/2+triangle_size, this.canvas.height/2); // start at the pointy end
+ region.lineTo(this.canvas.width/2-triangle_size/2, this.canvas.height/2+triangle_size);
+ region.lineTo(this.canvas.width/2-triangle_size/2, this.canvas.height/2-triangle_size);
+ region.closePath();
+ this.ctx.fillStyle = 'rgb(200, 200, 200, 0.75)';
+ this.ctx.fill(region);
+
+ // Draw circle
+ const circle_size = triangle_size*1.5;
+ this.ctx.lineWidth = this.canvas.height*0.02;
+ this.ctx.strokeStyle = 'rgb(200, 200, 200, 0.75)';
+ this.ctx.beginPath();
+ this.ctx.arc(
+ this.canvas.width/2,
+ this.canvas.height/2,
+ circle_size,
+ 0,
+ 2*Math.PI
+ );
+ this.ctx.stroke();
+ }
+
+ doDecodeProgress (stream, draw) {
+ this.doShowProgress(stream.pos, stream.data.length, draw);
+ }
+
+ /**
+ * @param{boolean=} draw Whether to draw progress bar or not;
+ * this is not idempotent because of translucency.
+ * Note that this means that the text will be unsynchronized
+ * with the progress bar on non-frames;
+ * but those are typically so small (GCE etc.) that it doesn't really matter
+ */
+ withProgress (stream, fn, draw) {
+ return block => {
+ fn?.(block);
+ this.doDecodeProgress(stream, draw);
+ };
+ }
+
+ getCanvasScale () {
+ let scale;
+ if (this.options.max_width && this.hdr && this.hdr.width > this.options.max_width) {
+ scale = this.options.max_width / this.hdr.width;
+ } else {
+ scale = 1;
+ }
+ return scale;
+ }
+
+ /**
+ * Makes an HTTP request to fetch a GIF
+ * @param { String } url
+ * @returns { Promise<String> } Returns a promise which resolves with the response data.
+ */
+ fetchGIF (url) {
+ const promise = getOpenPromise();
+ const h = new XMLHttpRequest();
+ h.open('GET', url, true);
+ h?.overrideMimeType('text/plain; charset=x-user-defined');
+ h.onload = () => {
+ if (h.status != 200) {
+ this.showError('xhr - response');
+ return promise.reject();
+ }
+ promise.resolve(h.response);
+ };
+ h.onprogress = (e) => (e.lengthComputable && this.doShowProgress(e.loaded, e.total, true));
+ h.onerror = () => this.showError('xhr');
+ h.send();
+ return promise;
+ }
+}
diff --git a/roles/reverseproxy/files/conversejs/src/shared/gif/stream.js b/roles/reverseproxy/files/conversejs/src/shared/gif/stream.js
new file mode 100644
index 0000000..e9306ff
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/shared/gif/stream.js
@@ -0,0 +1,43 @@
+
+export default class Stream {
+
+ constructor (data) {
+ if (data.toString().indexOf('ArrayBuffer') > 0) {
+ data = new Uint8Array(data);
+ }
+ this.data = data;
+ this.len = this.data.length;
+ this.pos = 0;
+ }
+
+ readByte () {
+ if (this.pos >= this.data.length) {
+ throw new Error('Attempted to read past end of stream.');
+ }
+ if (this.data instanceof Uint8Array)
+ return this.data[this.pos++];
+ else
+ return this.data.charCodeAt(this.pos++) & 0xFF;
+ }
+
+ readBytes (n) {
+ const bytes = [];
+ for (let i = 0; i < n; i++) {
+ bytes.push(this.readByte());
+ }
+ return bytes;
+ }
+
+ read (n) {
+ let s = '';
+ for (let i = 0; i < n; i++) {
+ s += String.fromCharCode(this.readByte());
+ }
+ return s;
+ }
+
+ readUnsigned () { // Little-endian.
+ const a = this.readBytes(2);
+ return (a[1] << 8) + a[0];
+ }
+}
diff --git a/roles/reverseproxy/files/conversejs/src/shared/gif/utils.js b/roles/reverseproxy/files/conversejs/src/shared/gif/utils.js
new file mode 100644
index 0000000..65f0761
--- /dev/null
+++ b/roles/reverseproxy/files/conversejs/src/shared/gif/utils.js
@@ -0,0 +1,319 @@
+/**
+ * @copyright Shachaf Ben-Kiki and the Converse.js contributors
+ * @description
+ * Started as a fork of Shachaf Ben-Kiki's jsgif library
+ * https://github.com/shachaf/jsgif
+ * @license MIT License
+ */
+
+function bitsToNum (ba) {
+ return ba.reduce(function (s, n) {
+ return s * 2 + n;
+ }, 0);
+}
+
+function byteToBitArr (bite) {
+ const a = [];
+ for (let i = 7; i >= 0; i--) {
+ a.push( !! (bite & (1 << i)));
+ }
+ return a;
+}
+
+function lzwDecode (minCodeSize, data) {
+ // TODO: Now that the GIF parser is a bit different, maybe this should get an array of bytes instead of a String?
+ let pos = 0; // Maybe this streaming thing should be merged with the Stream?
+ function readCode (size) {
+ let code = 0;
+ for (let i = 0; i < size; i++) {
+ if (data.charCodeAt(pos >> 3) & (1 << (pos & 7))) {
+ code |= 1 << i;
+ }
+ pos++;
+ }
+ return code;
+ }
+
+ const output = [];
+ const clearCode = 1 << minCodeSize;
+ const eoiCode = clearCode + 1;
+
+ let codeSize = minCodeSize + 1;
+ let dict = [];
+
+ const clear = function () {
+ dict = [];
+ codeSize = minCodeSize + 1;
+ for (let i = 0; i < clearCode; i++) {
+ dict[i] = [i];
+ }
+ dict[clearCode] = [];
+ dict[eoiCode] = null;
+ };
+
+ let code = clearCode;
+ let last;
+ clear();
+
+ while (true) { // eslint-disable-line no-constant-condition
+ last = code;
+ code = readCode(codeSize);
+
+ if (code === clearCode) {
+ clear();
+ continue;
+ }
+ if (code === eoiCode) break;
+
+ if (code < dict.length) {
+ if (last !== clearCode) {
+ dict.push(dict[last].concat(dict[code][0]));
+ }
+ }
+ else {
+ if (code !== dict.length) throw new Error('Invalid LZW code.');
+ dict.push(dict[last].concat(dict[last][0]));
+ }
+ output.push.apply(output, dict[code]);
+
+ if (dict.length === (1 << codeSize) && codeSize < 12) {
+ // If we're at the last code and codeSize is 12, the next code will be a clearCode, and it'll be 12 bits long.
+ codeSize++;
+ }
+ }
+ // I don't know if this is technically an error, but some GIFs do it.
+ //if (Math.ceil(pos / 8) !== data.length) throw new Error('Extraneous LZW bytes.');
+ return output;
+}
+
+
+function readSubBlocks (st) {
+ let size, data;
+ data = '';
+ do {
+ size = st.readByte();
+ data += st.read(size);
+ } while (size !== 0);
+ return data;
+}
+
+/**
+ * Parses GIF image color table information
+ * @param { Stream } st
+ * @param { Number } entries
+ */
+function parseCT (st, entries) { // Each entry is 3 bytes, for RGB.
+ const ct = [];
+ for (let i = 0; i < entries; i++) {
+ ct.push(st.readBytes(3));
+ }
+ return ct;
+}
+
+/**
+ * Parses GIF image information
+ * @param { Stream } st
+ * @param { ByteStream } img
+ * @param { Function } [callback]
+ */
+function parseImg (st, img, callback) {
+ function deinterlace (pixels, width) {
+ // Of course this defeats the purpose of interlacing. And it's *probably*
+ // the least efficient way it's ever been implemented. But nevertheless...
+ const newPixels = new Array(pixels.length);
+ const rows = pixels.length / width;
+ function cpRow (toRow, fromRow) {
+ const fromPixels = pixels.slice(fromRow * width, (fromRow + 1) * width);
+ newPixels.splice.apply(newPixels, [toRow * width, width].concat(fromPixels));
+ }
+
+ // See appendix E.
+ const offsets = [0, 4, 2, 1];
+ const steps = [8, 8, 4, 2];
+ let fromRow = 0;
+ for (let pass = 0; pass < 4; pass++) {
+ for (let toRow = offsets[pass]; toRow < rows; toRow += steps[pass]) {
+ cpRow(toRow, fromRow)
+ fromRow++;
+ }
+ }
+ return newPixels;
+ }
+
+ img.leftPos = st.readUnsigned();
+ img.topPos = st.readUnsigned();
+ img.width = st.readUnsigned();
+ img.height = st.readUnsigned();
+
+ const bits = byteToBitArr(st.readByte());
+ img.lctFlag = bits.shift();
+ img.interlaced = bits.shift();
+ img.sorted = bits.shift();
+ img.reserved = bits.splice(0, 2);
+ img.lctSize = bitsToNum(bits.splice(0, 3));
+
+ if (img.lctFlag) {
+ img.lct = parseCT(st, 1 << (img.lctSize + 1));
+ }
+ img.lzwMinCodeSize = st.readByte();
+
+ const lzwData = readSubBlocks(st);
+ img.pixels = lzwDecode(img.lzwMinCodeSize, lzwData);
+
+ if (img.interlaced) { // Move
+ img.pixels = deinterlace(img.pixels, img.width);
+ }
+ callback?.(img);
+}
+
+/**
+ * Parses GIF header information
+ * @param { Stream } st
+ * @param { Function } [callback]
+ */
+function parseHeader (st, callback) {
+ const hdr = {};
+ hdr.sig = st.read(3);
+ hdr.ver = st.read(3);
+ if (hdr.sig !== 'GIF') {
+ throw new Error('Not a GIF file.');
+ }
+ hdr.width = st.readUnsigned();
+ hdr.height = st.readUnsigned();
+
+ const bits = byteToBitArr(st.readByte());
+ hdr.gctFlag = bits.shift();
+ hdr.colorRes = bitsToNum(bits.splice(0, 3));
+ hdr.sorted = bits.shift();
+ hdr.gctSize = bitsToNum(bits.splice(0, 3));
+
+ hdr.bgColor = st.readByte();
+ hdr.pixelAspectRatio = st.readByte(); // if not 0, aspectRatio = (pixelAspectRatio + 15) / 64
+ if (hdr.gctFlag) {
+ hdr.gct = parseCT(st, 1 << (hdr.gctSize + 1));
+ }
+ callback?.(hdr);
+}
+
+function parseExt (st, block, handler) {
+
+ function parseGCExt (block) {
+ st.readByte(); // blocksize, always 4
+ const bits = byteToBitArr(st.readByte());
+ block.reserved = bits.splice(0, 3); // Reserved; should be 000.
+ block.disposalMethod = bitsToNum(bits.splice(0, 3));
+ block.userInput = bits.shift();
+ block.transparencyGiven = bits.shift();
+ block.delayTime = st.readUnsigned();
+ block.transparencyIndex = st.readByte();
+ block.terminator = st.readByte();
+ handler?.gce(block);
+ }
+
+ function parseComExt (block) {
+ block.comment = readSubBlocks(st);
+ handler.com && handler.com(block);
+ }
+
+ function parsePTExt (block) {
+ // No one *ever* uses this. If you use it, deal with parsing it yourself.
+ st.readByte(); // blocksize, always 12
+ block.ptHeader = st.readBytes(12);
+ block.ptData = readSubBlocks(st);
+ handler.pte && handler.pte(block);
+ }
+
+ function parseAppExt (block) {
+ function parseNetscapeExt (block) {
+ st.readByte(); // blocksize, always 3
+ block.unknown = st.readByte(); // ??? Always 1? What is this?
+ block.iterations = st.readUnsigned();
+ block.terminator = st.readByte();
+ handler.app && handler.app.NETSCAPE && handler.app.NETSCAPE(block);
+ }
+
+ function parseUnknownAppExt (block) {
+ block.appData = readSubBlocks(st);
+ // FIXME: This won't work if a handler wants to match on any identifier.
+ handler.app && handler.app[block.identifier] && handler.app[block.identifier](block);
+ }
+
+ st.readByte(); // blocksize, always 11
+ block.identifier = st.read(8);
+ block.authCode = st.read(3);
+ switch (block.identifier) {
+ case 'NETSCAPE':
+ parseNetscapeExt(block);
+ break;
+ default:
+ parseUnknownAppExt(block);
+ break;
+ }
+ }
+
+ function parseUnknownExt (block) {
+ block.data = readSubBlocks(st);
+ handler.unknown && handler.unknown(block);
+ }
+
+ block.label = st.readByte();
+ switch (block.label) {
+ case 0xF9:
+ block.extType = 'gce';
+ parseGCExt(block);
+ break;
+ case 0xFE:
+ block.extType = 'com';
+ parseComExt(block);
+ break;
+ case 0x01:
+ block.extType = 'pte';
+ parsePTExt(block);
+ break;
+ case 0xFF:
+ block.extType = 'app';
+ parseAppExt(block);
+ break;
+ default:
+ block.extType = 'unknown';
+ parseUnknownExt(block);
+ break;
+ }
+}
+
+/**
+ * @param { Stream } st
+ * @param { GIFParserHandlers } handler
+ */
+function parseBlock (st, handler) {
+ const block = {}
+ block.sentinel = st.readByte();
+ switch (String.fromCharCode(block.sentinel)) { // For ease of matching
+ case '!':
+ block.type = 'ext';
+ parseExt(st, block, handler);
+ break;
+ case ',':
+ block.type = 'img';
+ parseImg(st, block, handler?.img);
+ break;
+ case ';':
+ block.type = 'eof';
+ handler?.eof(block);
+ break;
+ default:
+ throw new Error('Unknown block: 0x' + block.sentinel.toString(16)); // TODO: Pad this with a 0.
+ }
+ if (block.type !== 'eof') setTimeout(() => parseBlock(st, handler), 0);
+}
+
+/**
+ * Takes a Stream and parses it for GIF data, calling the relevant handler
+ * methods on the passed in `handler` object.
+ * @param { Stream } st
+ * @param { GIFParserHandlers } handler
+ */
+export function parseGIF (st, handler={}) {
+ parseHeader(st, handler?.hdr);
+ setTimeout(() => parseBlock(st, handler), 0);
+}