diff options
Diffstat (limited to 'roles/reverseproxy/files/conversejs/src/shared/gif')
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); +} |