UNPKG

@blameitonyourisp/blurrid

Version:

Generate and render blurred placeholders for lazy loaded images.

806 lines (703 loc) 30 kB
// Copyright (c) 2024 James Reid. All rights reserved. // // This source code file is licensed under the terms of the MIT license, a copy // of which may be found in the LICENSE.md file in the root of this repository. // // For a template copy of the license see one of the following 3rd party sites: // - <https://opensource.org/licenses/MIT> // - <https://choosealicense.com/licenses/mit> // - <https://spdx.org/licenses/MIT> /** * @file Buffer class allowing control at bit level rather than byte level. * @author James Reid */ // @ts-check // @@imports-module import { DecoratedError } from "./decorate-cli.js" // @@body /** * Buffer class allowing control over an appropriately sized array buffer at the * bit level rather than at the byte level. */ class BitBuffer { #buffer #readPointer #lastReadSize #writePointer #lastWriteSize /** * Configure internal buffer property and required pointers. * * @param {object} obj - Configuration object argument. * @param {number} [obj.length] - Maximum character length of buffer when * converted to url-safe base64 string. * @param {number} [obj.size] - Size of BitBuffer in *bytes*. Defaults to * maximum allowable size as specified by the character length value. * @param {ArrayBuffer} [obj.buffer] - Internal array buffer. */ constructor({ length = 16, size = Math.floor(length * 6 / 8), buffer = new ArrayBuffer(size) } = {}) { // Assign internal array buffer for implementation of BitBuffer. this.#buffer = buffer // Assign internal read pointers. this.#readPointer = 0 this.#lastReadSize = 0 // Assign internal write pointers. this.#writePointer = 0 this.#lastWriteSize = 0 } /** * Write an integer directly to internal buffer, updating write pointers to * the end of the written data. * * @param {number} int - Integer to write to buffer. * @param {object} obj - Configuration object of optional arguments. * @param {number} [obj.size] - Size of buffer segment to write in bits. * @param {number} [obj.offset] - Offset of segment within buffer in bits. * @param {boolean} [obj.signed] - Write signed or unsigned integer. * @returns {number} Integer written to buffer. */ write(int, { size = BitBuffer.#bitLength(int), offset = this.#writePointer, signed = false } = {}) { // If all values are not writeable due to insufficient bits remaining // etc., then return no number. const writeable = this.#writeable() .append(int, { size, offset, signed }) if (!writeable.isWriteable) { return NaN } return this.#write(int, { size, offset, signed }) } /** * Write an integer to internal buffer with an absolute size declaration to * indicate how many bits are written (i.e. 5 additional bits written * indicating length of written data between 0 and 32 bits). Update write * pointers to the end of the written data. * * @param {number} int - Integer to write to buffer. * @param {object} obj - Configuration object of optional arguments. * @param {number} [obj.offset] - Offset of segment within buffer in bits. * @param {boolean} [obj.signed] - Write signed or unsigned integer. * @returns {number} Integer written to buffer. */ writeAbsolute(int, { offset = this.#writePointer, signed = false } = {}) { // Size declaration data with the size of buffer segment it will occupy. const resize = { value: BitBuffer.#bitLength(int), size: 5 } // If all values are not writeable due to insufficient bits remaining // etc., then return no number. const writeable = this.#writeable() .append(resize.value, { ...resize, offset }) .append(int, { signed }) if (!writeable.isWriteable) { return NaN } // Write both size declaration data and integer to buffer. this.#write(resize.value, { ...resize, offset }) const uint32 = this.#write(int, { signed }) return uint32 } /** * Write an integer to internal buffer with a relative size declaration to * indicate how many bits are written (i.e. 1 "sign" bit to indicate if more * or less bits than the previous write call are being written, and n "0" * bits where n is the relative size between the previous and next write * call sizes). Update write pointers to the end of the written data. * * @param {number} int - Integer to write to buffer. * @param {object} obj - Configuration object of optional arguments. * @param {number} [obj.offset] - Offset of segment within buffer in bits. * @param {boolean} [obj.signed] - Write signed or unsigned integer. * @returns {number} Integer written to buffer. */ writeRelative(int, { offset = this.#writePointer, signed = false } = {}) { // Write relative method relies on the first bit of the written integer // being "1", and therefore cannot write integer "0". if (int === 0) { int = 1 } // Size declaration data with the size of buffer segment it will occupy. const relativeSize = BitBuffer.#bitLength(int) - this.#lastWriteSize const resize = { // If increasing size, write a 1 bit shifted by the amount of bits // the data is bigger by, otherwise write a 0. Preserve required // bit length of size declaration using size property. value: relativeSize > 0 ? (1 << relativeSize) >>> 0 : 0, size: Math.abs(relativeSize) + 1 } // If all values are not writeable due to insufficient bits remaining // etc., then return no number. const writeable = this.#writeable() .append(resize.value, { ...resize, offset }) .append(int, { signed }) if (!writeable.isWriteable) { return NaN } // Write both size declaration data and integer to buffer. this.#write(resize.value, { ...resize, offset }) const uint32 = this.#write(int, { signed }) return uint32 } /** * Write a string of arbitrary length to the internal buffer. * * @param {string} string - String to write to buffer. * @param {object} obj - Configuration object of optional arguments. * @param {number} [obj.offset] - Offset of segment within buffer in bits. * @returns {string} String written to buffer. */ writeString(string, { offset = this.#writePointer } = {}) { // Size declaration data for bit length of length of string declaration. const resize = { value: BitBuffer.#bitLength(string.length), size: 5 } // If all characters are not writeable due to insufficient bits // remaining etc., then return empty string. let writeable = this.#writeable() .append(resize.value, { ...resize }) .append(string.length) for (let i = 0; i < string.length; i++) { writeable = writeable.append(0, { size: 8 }) } if (!writeable.isWriteable) { return "" } // Write string length declaration and string to buffer. this.#write(resize.value, { ...resize, offset }) this.#write(string.length) for (const char of string) { this.#write(char.charCodeAt(0), { size: 8 }) } return string } /** * Read an integer directly from internal buffer, updating read pointers to * the end of the read data. * * @param {number} size - Size of buffer segment to read. * @param {object} obj - Configuration object of optional arguments. * @param {number} [obj.offset] - Offset of segment within buffer in bits. * @param {boolean} [obj.signed] - Read signed or unsigned integer. * @returns {number} Integer read from buffer. */ read(size, { offset = this.#readPointer, signed = false } = {}) { // If all values are not readable due to insufficient bits remaining // etc., then return no number. const readable = this.#readable().append(size, { offset, signed }) if (!readable.isReadable) { return NaN } return this.#read(size, { offset, signed }) } /** * Read an integer from internal buffer, determining bit size of segment * by reading an absolute size declaration from the buffer indicating how * many bits should be read (i.e. 5 additional bits read before reading * integer to determine bit size of read segment between 0 and 32 bits). * Update read pointers to the end of the read data. * * @param {object} obj - Configuration object of optional arguments. * @param {number} [obj.offset] - Offset of segment within buffer in bits. * @param {boolean} [obj.signed] - Read signed or unsigned integer. * @returns {number} Integer read from buffer. */ readAbsolute({ offset = this.#readPointer, signed = false } = {}) { // Record read pointers for resetting if required. const readPointer = this.#readPointer const lastReadSize = this.#lastReadSize // Check if sufficient read bits remain to read both the size // declaration and integer. const readable = this.#readable().append(5, { offset }) let size = 0 if (readable.isReadable) { size = this.#read(5, { offset }) readable.append(size, { signed }) } // Reset pointers and return no number if insufficient remaining read // bits. if (!readable.isReadable || !size) { this.#readPointer = readPointer this.#lastReadSize = lastReadSize return NaN } return this.#read(size, { signed }) } /** * Read an integer from internal buffer, determining bit size of segment by * reading a relative size declaration from the buffer indicating how many * bit should be read (i.e. 1 "sign" bit to indicate if more or less bits * than the previous read call are being read, and n "0" bits where n is the * relative size between the previous and next read call sizes). Update read * pointers to the end of the read data. * * @param {object} obj - Configuration object of optional arguments. * @param {number} [obj.offset] - Offset of segment within buffer in bits. * @param {boolean} [obj.signed] - Read signed or unsigned integer. * @returns {number} Integer read from buffer. */ readRelative({ offset = this.#readPointer, signed = false } = {}) { // Record read pointers for resetting if required. const readPointer = this.#readPointer const lastReadSize = this.#lastReadSize // Get sign of relative size (i.e. are more or less bits to be read than // last read call). let sign = 1 const readable = this.#readable().append(1, { offset }) if (readable.isReadable) { sign = this.#read(1, { offset }) ? 1 : - 1 } // Get unsigned read size relative to size of last read call (reads // until first bit of integer to be read, then breaks). let relativeSize = 0 while (readable.append(1).isReadable) { if (this.#read(1)) { break } relativeSize++ } // Get size of integer to be read from buffer. const size = lastReadSize + sign * relativeSize readable.append(size, { signed }) // Reset pointers and return no number if insufficient remaining read // bits. if (!readable.isReadable || !size) { this.#readPointer = readPointer this.#lastReadSize = lastReadSize return NaN } // Decrement read pointer to account for first bit of integer having // been read above. this.#readPointer-- return this.#read(size, { signed }) } /** * Read a string of arbitrary length from the internal buffer. * * @param {object} obj - Configuration object of optional arguments. * @param {number} [obj.offset] - Offset of segment within buffer in bits. * @returns {string} String read from buffer. */ readString({ offset = this.#readPointer } = {}) { // Record read pointers for resetting if required. const readPointer = this.#readPointer const lastReadSize = this.#lastReadSize // Get bit size of string length declaration. const readable = this.#readable().append(5, { offset }) let lengthSize = 0 if (readable.isReadable) { lengthSize = this.#read(5, { offset }) } readable.append(lengthSize) // Get string length. let length = 0 if (readable.isReadable) { length = this.#read(lengthSize) } // Reset pointers and return no number if insufficient remaining read // bits. if (!readable.isReadable || !length) { this.#readPointer = readPointer this.#lastReadSize = lastReadSize return "" } let string = "" for (let i = 0; i < length; i++) { // Ensure next byte (8 bit character) is readable. readable.append(8) // Reset pointers and return no number if insufficient remaining // read bits. if (!readable.isReadable) { this.#readPointer = readPointer this.#lastReadSize = lastReadSize return "" } // Read character from buffer and append to string. string += String.fromCharCode(this.#read(8)) } return string } /** * Copy data from source buffer (current buffer instance) to a target buffer * passed in the arguments. If no target buffer passed, new BitBuffer * instantiated with a length based on number of copied bits. Will update * read/write pointers in both source and target buffers. * * @param {object} obj - Configuration object of optional arguments. * @param {BitBuffer} [obj.target] - Target buffer to copy to. * @param {number} [obj.targetStart] - Start bit in target buffer. * @param {number} [obj.sourceStart] - Start bit in source buffer. * @param {number} [obj.sourceEnd] - End bit in source buffer. * @returns {BitBuffer} Target buffer with data copied from source buffer. */ copy({ target, targetStart = target?.writePointer || 0, sourceStart = 0, sourceEnd = this.bitLength } = {}) { // Throw error if source start or end bounds are out of buffer range. if (sourceStart < 0 || sourceEnd > this.bitLength) { throw new DecoratedError({ name: "BitBufferError", message: "Requested bits out of source buffer range", "source-start": sourceStart, "source-end": sourceEnd, "source-bit-length": this.bitLength }) } // Get number of source bits, and minimum size of target buffer in bytes // to store the data from the source buffer. const sourceBits = sourceEnd - sourceStart const targetSize = Math.ceil((sourceBits + targetStart) / 8) // Get available write bits in target buffer, instantiating a BitBuffer // of the correct size if none is passed in arguments. target ??= new BitBuffer({ size: targetSize }) const targetBits = target.bitLength - targetStart // Throw error if not sufficient bits remaining in target buffer. if (sourceBits > targetBits) { throw new DecoratedError({ name: "BitBufferError", message: "Source bits exceed bits available in target buffer", "source-bits": sourceBits, "target-bits": targetBits }) } // Copy data bits from source buffer to target buffer. for (let i = 0; i < sourceBits; i++) { target.write( this.#read(1, { offset: sourceStart + i }), { size: 1, offset: targetStart + i } ) } return target } /** * Convert buffer to serialized string of base-64 url-safe characters. * * @returns {string} Serialized buffer string. */ toString() { // Initialize serialized string and pointers to track string fragments. let string = "" let pointer = 0 let uint24 = 0 // Loop over buffer, adding 4 character fragments to serialized string // for every 24 bits consumed from buffer (24-bit blocks consumed in // 3-byte blocks at time). const view = new Uint8Array(this.#buffer) for (let i = 0; i < Math.ceil(this.byteLength / 3) * 3; i++) { const byte = view[i] || 0 uint24 = (uint24 | (byte << 16 - pointer * 8)) >>> 0 pointer = ++pointer % 3 if (!pointer) { string += BitBuffer.#uint24ToB64(uint24) uint24 = 0 } } return string } /** * Create object containing */ #writeable() { // const tracer = { writeable: true, offset: this.#writePointer } /** * * @param {number} int * @param {object} obj * @param {number} [obj.size] * @param {number} [obj.offset] * @param {boolean} [obj.signed] */ const append = (int, { size = BitBuffer.#bitLength(int), offset = tracer.offset, signed = false } = {}) => { if (tracer.writeable) { const uint32 = Math.abs(int) const bitsRemaining = this.bitLength - offset tracer.writeable = !signed && int < 0 ? false : BitBuffer.#bitLength(uint32) > size ? false : !Number.isInteger(uint32) ? false : size < 0 || size > 32 ? false : size + (signed ? 1 : 0) > bitsRemaining ? false : true } tracer.offset += size return { append, get isWriteable() { return tracer.writeable } } } return { append, get isWriteable() { return tracer.writeable } } } /** * Write sanitized integer directly to internal buffer, updating write * pointers to the end of the written data. This private method is called by * other class write methods *after* values have been checked to ensure that * they are not out of range, or will not fit in the remaining empty buffer * bits. * * @param {number} int - Integer to write to buffer. * @param {object} obj - Configuration object of optional arguments. * @param {number} [obj.size] - Size of buffer segment to write in bits. * @param {number} [obj.offset] - Offset of segment within buffer in bits. * @param {boolean} [obj.signed] - Write signed or unsigned integer. * @returns {number} Integer written to buffer. */ #write(int, { size = BitBuffer.#bitLength(int), offset = this.#writePointer, signed = false } = {}) { // Get absolute value of integer to write. const uint32 = Math.abs(int) const { view, byteLength, subBit } = this.#getView(size, offset) for (let i = 0; i < byteLength; i++) { const byte = view.getUint8(i) let uint8 = 0 for (let j = 0; j < 8; j++) { const index = i * 8 + j const bit = index < subBit || index > subBit + size ? byte << 24 + j >>> 31 : uint32 << (32 - size) + (index - subBit) >>> 31 uint8 = uint8 | (bit << 7 - j) } view.setUint8(i, uint8) } // Update write pointer and add sign bit *after* written integer. // NOTE: The sign bit is written at the end of integers in the BitBuffer // as this saves a bit when using the writeRelative and readRelative // methods. This is because the relative size declaration can be assumed // to end at the first non-zero bit (i.e. the start of the number), // rather than requiring an extra "1" end bit if the "1"/"0" sign bit // was written at the start of the number. this.#writePointer = offset + size if (signed) { this.#write(int >= 0 ? 1 : 0, { size: 1 }) } // Update last write size *after* sign bit such that the sign bit is // *not* considered as the last integer size written. this.#lastWriteSize = size return int } /** * */ #readable() { const tracer = { readable: true, offset: this.#readPointer } const append = (/** @type {number} */ size, { offset = tracer.offset, signed = false } = {}) => { if (tracer.readable) { const bitsRemaining = this.bitLength - offset tracer.readable = size < 0 || size > 32 ? false : size + (signed ? 1 : 0) > bitsRemaining ? false : true } tracer.offset += size return { append, get isReadable() { return tracer.readable } } } return { append, get isReadable() { return tracer.readable } } } /** * Read sanitized integer directly from internal buffer, updating write * pointers to the end of the read data. This private method is called by * other class read methods *after* values have been checked to ensure that * they are not out of range. * * @param {number} size - Size of buffer segment to read. * @param {object} obj - Configuration object of optional arguments. * @param {number} [obj.offset] - Offset of segment within buffer in bits. * @param {boolean} [obj.signed] - Read signed or unsigned integer. * @returns {number} Integer read from buffer. */ #read(size, { offset = this.#readPointer, signed = false } = {}) { const { view, byteLength, subBit } = this.#getView(size, offset) let uint32 = 0 for (let i = 0; i < byteLength; i++) { const offset = 24 + subBit - i * 8 uint32 = offset >= 0 ? (uint32 | view.getUint8(i) << offset) >>> 0 : (uint32 | view.getUint8(i) >>> - offset) >>> 0 } this.#readPointer = offset + size const sign = signed && this.#read(1) === 0 ? - 1 : 1 this.#lastReadSize = size return sign * (uint32 >>> 32 - size) } /** * * @param {*} size * @param {*} offset * @returns {{view:DataView,byteLength:number,subBit:number}} Object * containing requested dataview of BitBuffer at the given offset. */ #getView(size, offset) { const startByte = Math.floor(offset / 8) const subBit = offset - 8 * startByte const byteLength = Math.ceil((subBit + size) / 8) if (startByte + byteLength > this.byteLength) { // throw new BitBuffer.#RangeError(size, offset) } const view = new DataView(this.#buffer, startByte, byteLength) return { view, byteLength, subBit } } /** * Get length of buffer in bits. * * @returns {number} Bit length of buffer. */ get bitLength() { return this.byteLength << 3 } /** * Get length of buffer in bytes. * * @returns {number} Byte length of buffer. */ get byteLength() { return this.#buffer.byteLength } /** * Get current read pointer. * * @returns {number} Internal read pointer. */ get readPointer() { return this.#readPointer } /** * Safely set current read pointer, observing bit size of buffer. * * @param {number} pointer - Updated read pointer. * @returns {void} */ set readPointer(pointer) { // Ignore updated pointer if it is out of range. if (pointer < 0 || pointer > this.bitLength) { return } // Update internal pointer. this.#readPointer = pointer } /** * Get last read size in bits. * * @returns {number} Internal last read size in bits. */ get lastReadSize() { return this.#lastReadSize } /** * Safely set last read size, observing max and min integer bit sizes. * * @param {number} size - Updated last read size in bits. * @returns {void} */ set lastReadSize(size) { // Ignore updated size if not within max and min integer bit sizes. if (size < 0 || size > 32) { return } // Update internal read size. this.#lastReadSize = size } /** * Get current write pointer. * * @returns {number} Internal write pointer. */ get writePointer() { return this.#writePointer } /** * Safely set current write pointer, observing bit size of buffer. * * @param {number} pointer - Updated write pointer. * @returns {void} */ set writePointer(pointer) { // Ignore updated pointer if it is out of range. if (pointer < 0 || pointer > this.bitLength) { return } // Update internal pointer. this.#writePointer = pointer } /** * Get last write size in bits. * * @returns {number} Internal last write size in bits. */ get lastWriteSize() { return this.#lastWriteSize } /** * Safely set last write size, observing max and min integer bit sizes. * * @param {number} size - Updated last write size in bits. * @returns {void} */ set lastWriteSize(size) { // Ignore updated size if not within max and min integer bit sizes. if (size < 0 || size > 32) { return } // Update internal write size. this.#lastWriteSize = size } /** * Decode serialized url-safe base 64 BitBuffer string, returning a new * BitBuffer instance containing the data from the original serialized * buffer. * * @param {string} string - Url-safe base 64 encoded BitBuffer string. * @returns {BitBuffer} Decoded BitBuffer instance. */ static from(string) { // Throw error if input string not correctly encoded. if (!string.match(/^[A-Za-z0-9\-_]*$/)) { throw new DecoratedError({ name: "BitBufferError", message: "Encoded string is not url-safe base 64 encoded", "encoded-string": string }) } // Create new BitBuffer instance based on length of input string. const buffer = new BitBuffer({ size: Math.ceil(string.length * 3 / 4) }) // Split input string into 4-character segments, convert each segment // into 24-bit unsigned integer, and write to new BitBuffer instance. const regex = /[A-Za-z0-9\-_]{1,4}/g for (const match of string.match(regex) || []) { const uint24 = BitBuffer.#b64ToUint24(match.padEnd(4, "A")) buffer.write(uint24, { size: 24 }) } // Reset read and write pointers. buffer.writePointer = 0 buffer.readPointer = 0 return buffer } /** * Convert 4 character url-safe base 64 string to 24-bit unsigned integer. * * @param {string} string - 4 character url-safe base 64 string. * @returns {number} Unsigned 24 bit integer. */ static #b64ToUint24(string) { let uint24 = 0 // Loop over characters of input string, converting each url-safe base // 64 character to a 6-bit integer. Bitwise SHIFT the result such that // the 6 data bits occupy a unique sector of the uint24 output, and // bitwise AND the result with the uint24 output. for (const [index, char] of string.split("").entries()) { const uint6 = BitBuffer.#dict.indexOf(char) uint24 = (uint24 | (uint6 << 18 - index * 6)) >>> 0 } return uint24 } /** * Convert 24-bit unsigned integer to 4 character url-safe base 64 string. * * @param {number} uint24 - Unsigned 24 bit integer. * @returns {string} 4 character url-safe base 64 string. */ static #uint24ToB64(uint24) { let string = "" // Divide 24-bit integer into 6-bit segments, appending a url-safe base // 64 character to output string for each segment. for (let i = 0; i < 4; i++) { const uint6 = uint24 >>> 18 - i * 6 << 26 >>> 26 string += BitBuffer.#dict[uint6] } return string } /** * Get bit length of a given number. * * @param {number} value - Input number. * @returns {number} Bit length. */ static #bitLength(value) { return Math.abs(value).toString(2).length } /** * Get url-safe base64 character dictionary, which uses different padding * characters to the standard base64 encoding in node. Please see * [here](https://developer.mozilla.org/en-US/docs/Glossary/Base64) for more * information on base64 encoding. For url safe characters, see rfc4648 * [here](https://datatracker.ietf.org/doc/html/rfc4648#section-5). * * @returns {string} Url-safe base64 dictionary string. */ static get #dict() { return "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + // Uppercase alpha characters. "abcdefghijklmnopqrstuvwxyz" + // Lowercase alpha characters. "0123456789" + // Number characters. "-_" // Url-safe padding characters for total of 64 characters. } } // @@exports export { BitBuffer }