gif-tools
Version:
A robust, zero-dependency TypeScript library for creating GIF files with support for both static and animated GIFs. Built with modern TypeScript features and designed to work in both Node.js and browser environments.
377 lines (376 loc) • 12.2 kB
JavaScript
/**
* LZW (Lempel-Ziv-Welch) compression for GIF encoding
*/
import { GifEncodingError } from './types.js';
import { MAX_CODE_VALUE } from './utils.js';
/**
* Converts LZW codes to byte array with bit packing
*/
class CodeToBytesConverter {
constructor() {
this.output = [];
this.remainingBits = 0;
this.remainingValue = 0;
}
/**
* Pushes a code with specified bit length
*/
push(code, numBits) {
if (code < 0 || numBits <= 0) {
throw new GifEncodingError(`Invalid code or bit length: code=${code}, bits=${numBits}`);
}
while (numBits > 0) {
// Calculate how many bits we can take from the code
const bitsToTake = Math.min(numBits, 8 - this.remainingBits);
// Extract the bits we need from the code
const mask = (1 << bitsToTake) - 1;
const bits = code & mask;
// Add these bits to our remaining value
this.remainingValue |= bits << this.remainingBits;
this.remainingBits += bitsToTake;
if (this.remainingBits >= 8) {
// Output complete byte
this.output.push(this.remainingValue & 0xff);
// Shift remaining value and adjust bit count
this.remainingValue >>= 8;
this.remainingBits -= 8;
}
// Move to next part of the code
code >>= bitsToTake;
numBits -= bitsToTake;
}
}
/**
* Flushes any remaining bits and returns the byte array
*/
flush() {
// Output any remaining bits
if (this.remainingBits > 0) {
this.output.push(this.remainingValue & 0xff);
}
const result = new Uint8Array(this.output);
// Reset state
this.output = [];
this.remainingBits = 0;
this.remainingValue = 0;
return result;
}
}
/**
* LZW compression dictionary
*/
class LZWDictionary {
constructor(initialCodeSize) {
this.initialCodeSize = initialCodeSize;
this.dictionary = new Map();
this.nextCode = 0;
this.clearCode = 1 << initialCodeSize;
this.endCode = this.clearCode + 1;
this.reset();
}
get clear() {
return this.clearCode;
}
get end() {
return this.endCode;
}
get next() {
return this.nextCode;
}
/**
* Resets the dictionary to initial state
*/
reset() {
this.dictionary.clear();
this.nextCode = this.endCode + 1;
// Initialize with single-character codes
for (let i = 0; i < this.clearCode; i++) {
this.dictionary.set(String.fromCharCode(i), i);
}
}
/**
* Checks if a string exists in the dictionary
*/
has(str) {
return this.dictionary.has(str);
}
/**
* Gets the code for a string
*/
get(str) {
return this.dictionary.get(str);
}
/**
* Adds a new string to the dictionary
*/
add(str) {
if (this.nextCode > MAX_CODE_VALUE) {
throw new GifEncodingError('Dictionary overflow');
}
const code = this.nextCode++;
this.dictionary.set(str, code);
return code;
}
/**
* Checks if dictionary needs to be reset
*/
shouldReset() {
return this.nextCode > MAX_CODE_VALUE;
}
}
/**
* Calculates the number of bits needed for a code
*/
function calculateCodeBits(maxCode, initialBits) {
if (maxCode < 1 << initialBits) {
return initialBits;
}
let bits = initialBits;
let max = 1 << bits;
while (max <= maxCode && bits < 12) {
bits++;
max <<= 1;
}
return Math.min(bits, 12);
}
/**
* Compresses data using LZW algorithm
*/
export function compressLZW(data, initialCodeSize) {
if (data.length === 0) {
throw new GifEncodingError('Cannot compress empty data');
}
if (initialCodeSize < 1 || initialCodeSize > 8) {
throw new GifEncodingError(`Invalid initial code size: ${initialCodeSize}. Must be 1-8`);
}
const converter = new CodeToBytesConverter();
const dictionary = new LZWDictionary(initialCodeSize);
// Start with clear code
let currentBits = initialCodeSize + 1;
converter.push(dictionary.clear, currentBits);
let currentString = '';
for (let i = 0; i < data.length; i++) {
const char = String.fromCharCode(data[i]);
const newString = currentString + char;
if (dictionary.has(newString)) {
currentString = newString;
}
else {
// Output the code for current string
const code = dictionary.get(currentString);
if (code === undefined) {
throw new GifEncodingError(`String not found in dictionary: ${currentString}`);
}
converter.push(code, currentBits);
// Add new string to dictionary if there's room
if (!dictionary.shouldReset()) {
dictionary.add(newString);
// Check if we need more bits - increase when next code would require more bits
const newBits = calculateCodeBits(dictionary.next - 1, initialCodeSize + 1);
if (newBits > currentBits && newBits <= 12) {
currentBits = newBits;
}
}
else {
// Dictionary is full, send clear code and reset
converter.push(dictionary.clear, currentBits);
dictionary.reset();
currentBits = initialCodeSize + 1;
}
currentString = char;
}
}
// Output final string
if (currentString.length > 0) {
const code = dictionary.get(currentString);
if (code === undefined) {
throw new GifEncodingError(`Final string not found in dictionary: ${currentString}`);
}
converter.push(code, currentBits);
}
// End with end-of-information code
converter.push(dictionary.end, currentBits);
return converter.flush();
}
/**
* Reads codes from byte array with bit unpacking
*/
class BytesToCodeConverter {
constructor(data) {
this.data = data;
this.position = 0;
this.bitBuffer = 0;
this.bitsInBuffer = 0;
}
/**
* Reads the next code with specified bit length
*/
readCode(numBits) {
if (numBits <= 0 || numBits > 16) {
throw new GifEncodingError(`Invalid bit length: ${numBits}`);
}
// Ensure we have enough bits in buffer
while (this.bitsInBuffer < numBits && this.position < this.data.length) {
this.bitBuffer |= this.data[this.position++] << this.bitsInBuffer;
this.bitsInBuffer += 8;
}
// Check if we have enough bits
if (this.bitsInBuffer < numBits) {
return null;
}
// Extract the code
const mask = (1 << numBits) - 1;
const code = this.bitBuffer & mask;
// Remove used bits from buffer
this.bitBuffer >>= numBits;
this.bitsInBuffer -= numBits;
return code;
}
/**
* Checks if there's more data to read
*/
hasMore() {
return this.position < this.data.length || this.bitsInBuffer > 0;
}
}
/**
* LZW decompression dictionary
*/
class LZWDecodeDictionary {
constructor(initialCodeSize) {
this.initialCodeSize = initialCodeSize;
this.dictionary = [];
this.nextCode = 0;
this.clearCode = 1 << initialCodeSize;
this.endCode = this.clearCode + 1;
this.reset();
}
get clear() {
return this.clearCode;
}
get end() {
return this.endCode;
}
get next() {
return this.nextCode;
}
/**
* Resets the dictionary to initial state
*/
reset() {
this.dictionary.length = 0;
// Initialize with single-character codes (0 to clearCode-1)
for (let i = 0; i < this.clearCode; i++) {
this.dictionary[i] = String.fromCharCode(i);
}
// Reserve slots for clear and end codes (but don't define them as strings)
this.dictionary[this.clearCode] = undefined; // Clear code
this.dictionary[this.endCode] = undefined; // End code
// Next available code starts after end code
this.nextCode = this.endCode + 1;
}
/**
* Gets the string for a code
*/
get(code) {
if (code >= this.dictionary.length) {
return undefined;
}
return this.dictionary[code];
}
/**
* Adds a new string to the dictionary
*/
add(str) {
if (this.nextCode < MAX_CODE_VALUE) {
this.dictionary[this.nextCode++] = str;
}
}
/**
* Checks if dictionary needs to be reset
*/
shouldReset() {
return this.nextCode >= MAX_CODE_VALUE;
}
}
/**
* Decompresses LZW-compressed data
*/
export function decompressLZW(compressedData, initialCodeSize) {
if (compressedData.length === 0) {
return new Uint8Array(0);
}
if (initialCodeSize < 1 || initialCodeSize > 8) {
throw new GifEncodingError(`Invalid initial code size: ${initialCodeSize}. Must be 1-8`);
}
const converter = new BytesToCodeConverter(compressedData);
const dictionary = new LZWDecodeDictionary(initialCodeSize);
const output = [];
let currentBits = initialCodeSize + 1;
let previousString = '';
while (converter.hasMore()) {
const code = converter.readCode(currentBits);
if (code === null)
break;
// Handle special codes
if (code === dictionary.clear) {
dictionary.reset();
currentBits = initialCodeSize + 1;
previousString = '';
continue;
}
if (code === dictionary.end) {
break;
}
let currentString;
if (code < dictionary.next) {
// Code exists in dictionary
currentString = dictionary.get(code) || '';
}
else if (code === dictionary.next) {
// Special case: code not yet in dictionary
if (previousString.length === 0) {
throw new GifEncodingError(`Invalid LZW sequence: first code ${code} not in dictionary`);
}
currentString = previousString + previousString.charAt(0);
}
else {
throw new GifEncodingError(`Invalid LZW code: ${code} (dictionary size: ${dictionary.next}, bits: ${currentBits}, max for bits: ${(1 << currentBits) - 1})`);
}
// Output the string
for (let i = 0; i < currentString.length; i++) {
output.push(currentString.charCodeAt(i));
}
// Add new entry to dictionary if there's room and we have a previous string
if (previousString.length > 0 && dictionary.next < MAX_CODE_VALUE) {
dictionary.add(previousString + currentString.charAt(0));
// Check if we need more bits AFTER adding to dictionary
if (dictionary.next >= 1 << currentBits && currentBits < 12) {
currentBits++;
}
}
previousString = currentString;
}
return new Uint8Array(output);
}
/**
* Validates LZW parameters
*/
export function validateLZWParams(data, codeSize) {
if (!(data instanceof Uint8Array)) {
throw new GifEncodingError('Data must be Uint8Array');
}
if (data.length === 0) {
throw new GifEncodingError('Data cannot be empty');
}
if (!Number.isInteger(codeSize) || codeSize < 1 || codeSize > 8) {
throw new GifEncodingError(`Invalid LZW code size: ${codeSize}. Must be integer 1-8`);
}
// Check if all data values are within the valid range for the code size
const maxValue = (1 << codeSize) - 1;
for (let i = 0; i < data.length; i++) {
if (data[i] > maxValue) {
throw new GifEncodingError(`Data value ${data[i]} at index ${i} exceeds maximum ${maxValue} for code size ${codeSize}`);
}
}
}