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.
323 lines (322 loc) • 12.1 kB
JavaScript
/**
* Modern GIF Writer Implementation
*
* This implementation provides a clean, type-safe API for writing GIF files
* with support for both static and animated GIFs.
*/
import { DisposalMethod, GifValidationError, GifEncodingError, } from './types.js';
import { GIF_SIGNATURE, GIF_VERSION_89A, GIF_TRAILER, validateDimensions, validatePalette, calculateColorTableSize, padColorTable, write16BitLE, msToGifDelay, NETSCAPE_APPLICATION_IDENTIFIER, NETSCAPE_APPLICATION_AUTHENTICATION_CODE, } from './utils.js';
import { compressLZW, validateLZWParams } from './lzw.js';
/**
* Writes GIF data to a buffer or stream
*/
export class ByteArrayOutputStream {
constructor() {
this.data = [];
}
writeByte(byte) {
if (!Number.isInteger(byte) || byte < 0 || byte > 255) {
throw new GifValidationError(`Invalid byte value: ${byte}`);
}
this.data.push(byte);
}
writeBytes(bytes) {
if (bytes instanceof Uint8Array) {
for (let i = 0; i < bytes.length; i++) {
this.data.push(bytes[i]);
}
}
else {
for (const byte of bytes) {
this.writeByte(byte);
}
}
}
/**
* Returns the written data as Uint8Array
*/
toUint8Array() {
return new Uint8Array(this.data);
}
/**
* Returns the written data as Buffer (Node.js) or Uint8Array (browser)
*/
toBuffer() {
// In Node.js, return Buffer; in browser, return Uint8Array
try {
const globalBuffer = globalThis.Buffer;
if (globalBuffer && typeof globalBuffer.from === 'function') {
return globalBuffer.from(this.data);
}
}
catch (_a) {
// Fall through to Uint8Array fallback
}
// Fallback to Uint8Array for browser compatibility
return this.toUint8Array();
}
/**
* Clears all written data
*/
clear() {
this.data = [];
}
/**
* Returns the current size in bytes
*/
get size() {
return this.data.length;
}
}
/**
* Main GIF Writer class
*/
export class GifWriter {
constructor(output) {
this.hasWrittenHeader = false;
this.hasWrittenLogicalScreen = false;
this.frameCount = 0;
this.output = output !== null && output !== void 0 ? output : new ByteArrayOutputStream();
}
/**
* Writes the GIF header (signature and version)
*/
writeHeader() {
if (this.hasWrittenHeader) {
throw new GifEncodingError('Header already written');
}
this.output.writeBytes(GIF_SIGNATURE);
this.output.writeBytes(GIF_VERSION_89A);
this.hasWrittenHeader = true;
return this;
}
/**
* Writes the logical screen descriptor and optional global color table
*/
writeLogicalScreen(width, height, options = {}) {
if (!this.hasWrittenHeader) {
throw new GifEncodingError('Must write header before logical screen');
}
if (this.hasWrittenLogicalScreen) {
throw new GifEncodingError('Logical screen already written');
}
validateDimensions({ width, height });
const { colors, sorted = false, backgroundColorIndex = 0, pixelAspectRatio = 0, } = options;
// Determine color table settings
let globalColorTableFlag = false;
let colorTableSize = 0;
let paddedColorTable;
if (colors && colors.length > 0) {
validatePalette(colors);
globalColorTableFlag = true;
colorTableSize = calculateColorTableSize(colors.length / 3);
paddedColorTable = padColorTable(colors, colorTableSize);
}
// Write logical screen descriptor
this.output.writeBytes(write16BitLE(width));
this.output.writeBytes(write16BitLE(height));
// Packed fields
const packedFields = (globalColorTableFlag ? 0x80 : 0x00) | // Global Color Table Flag
0x70 | // Color Resolution (always 111)
(sorted ? 0x08 : 0x00) | // Sort Flag
colorTableSize; // Size of Global Color Table
this.output.writeByte(packedFields);
this.output.writeByte(backgroundColorIndex & 0xff);
this.output.writeByte(pixelAspectRatio & 0xff);
// Write global color table if present
if (paddedColorTable) {
this.output.writeBytes(paddedColorTable);
}
this.hasWrittenLogicalScreen = true;
return this;
}
/**
* Writes animation control information (Netscape extension)
*/
writeAnimationInfo(options = {}) {
if (!this.hasWrittenLogicalScreen) {
throw new GifEncodingError('Must write logical screen before animation info');
}
const { loops = 0 } = options;
const loopCount = Math.max(0, Math.min(65535, loops));
this.output.writeBytes([
0x21, // Extension Introducer
0xff, // Application Extension Label
11, // Block Size
...NETSCAPE_APPLICATION_IDENTIFIER,
...NETSCAPE_APPLICATION_AUTHENTICATION_CODE,
3, // Sub-block size
0x01, // Loop indicator
...write16BitLE(loopCount),
0x00, // Block terminator
]);
return this;
}
/**
* Writes a graphics control extension
*/
writeGraphicsControl(options) {
const { delay = 0, disposal = DisposalMethod.RestoreToBackground, transparentIndex = -1, } = options;
const delayTime = msToGifDelay(delay);
const hasTransparency = transparentIndex >= 0 && transparentIndex <= 255;
this.output.writeBytes([
0x21, // Extension Introducer
0xf9, // Graphics Control Label
0x04, // Block Size
]);
// Packed fields
const packedFields = ((disposal & 0x07) << 2) | // Disposal Method
(hasTransparency ? 0x01 : 0x00); // Transparent Color Flag
this.output.writeByte(packedFields);
this.output.writeBytes(write16BitLE(delayTime));
this.output.writeByte(hasTransparency ? transparentIndex & 0xff : 0);
this.output.writeByte(0x00); // Block terminator
}
/**
* Writes image descriptor
*/
writeImageDescriptor(image, options) {
const { left = 0, top = 0 } = options;
validatePalette(image.palette);
const colorTableSize = calculateColorTableSize(image.palette.length / 3);
const useLocalColorTable = true; // Always use local color table for simplicity
this.output.writeByte(0x2c); // Image Separator
this.output.writeBytes(write16BitLE(left));
this.output.writeBytes(write16BitLE(top));
this.output.writeBytes(write16BitLE(image.width));
this.output.writeBytes(write16BitLE(image.height));
// Packed fields
const packedFields = (useLocalColorTable ? 0x80 : 0x00) | // Local Color Table Flag
0x00 | // Interlace Flag (not supported)
0x00 | // Sort Flag
colorTableSize; // Size of Local Color Table
this.output.writeByte(packedFields);
}
/**
* Writes image data with LZW compression
*/
writeImageData(image) {
// Validate image data
if (image.data.length !== image.width * image.height) {
throw new GifValidationError(`Image data length mismatch: expected ${image.width * image.height}, got ${image.data.length}`);
}
const colorCount = image.palette.length / 3;
const minCodeSize = colorCount <= 2 ? 2 : Math.ceil(Math.log2(colorCount));
const lzwCodeSize = Math.max(2, minCodeSize); // GIF minimum is 2
// Validate that all pixel values are within palette range
const maxIndex = colorCount - 1;
for (let i = 0; i < image.data.length; i++) {
if (image.data[i] > maxIndex) {
throw new GifValidationError(`Pixel value ${image.data[i]} at index ${i} exceeds palette size ${colorCount}`);
}
}
validateLZWParams(image.data, lzwCodeSize);
const compressedData = compressLZW(image.data, lzwCodeSize);
// Write LZW minimum code size
this.output.writeByte(lzwCodeSize);
// Write compressed data in sub-blocks
this.writeDataSubBlocks(compressedData);
// Block terminator
this.output.writeByte(0x00);
}
/**
* Writes data in sub-blocks (max 255 bytes each)
*/
writeDataSubBlocks(data) {
let offset = 0;
while (offset < data.length) {
const blockSize = Math.min(255, data.length - offset);
this.output.writeByte(blockSize);
for (let i = 0; i < blockSize; i++) {
this.output.writeByte(data[offset + i]);
}
offset += blockSize;
}
}
/**
* Writes a single image frame
*/
writeImage(image, options = {}) {
if (!this.hasWrittenLogicalScreen) {
throw new GifEncodingError('Must write logical screen before images');
}
// Validate image
validateDimensions(image);
validatePalette(image.palette);
// Write graphics control extension for animated GIFs or if transparency is needed
const needsGraphicsControl = this.frameCount > 0 ||
options.delay !== undefined ||
options.disposal !== undefined ||
options.transparentIndex !== undefined;
if (needsGraphicsControl) {
this.writeGraphicsControl(options);
}
// Write image descriptor
this.writeImageDescriptor(image, options);
// Write local color table
const paddedPalette = padColorTable(image.palette, calculateColorTableSize(image.palette.length / 3));
this.output.writeBytes(paddedPalette);
// Write image data
this.writeImageData(image);
this.frameCount++;
return this;
}
/**
* Writes the GIF trailer
*/
writeTrailer() {
this.output.writeByte(GIF_TRAILER);
return this;
}
/**
* Convenience method to write a complete static GIF
*/
writeStaticGif(image, globalOptions = {}, imageOptions = {}) {
return this.writeHeader()
.writeLogicalScreen(image.width, image.height, globalOptions)
.writeImage(image, imageOptions)
.writeTrailer();
}
/**
* Convenience method to write a complete animated GIF
*/
writeAnimatedGif(images, globalOptions = {}, animationOptions = {}, imageOptions = []) {
if (images.length === 0) {
throw new GifValidationError('Cannot create animated GIF with no images');
}
// Use first image dimensions for logical screen
const { width, height } = images[0];
this.writeHeader()
.writeLogicalScreen(width, height, globalOptions)
.writeAnimationInfo(animationOptions);
images.forEach((image, index) => {
const options = imageOptions[index] || {};
this.writeImage(image, options);
});
return this.writeTrailer();
}
/**
* Returns the output stream (useful when using default ByteArrayOutputStream)
*/
getOutput() {
return this.output;
}
/**
* Gets the written data as Uint8Array (only works with ByteArrayOutputStream)
*/
toUint8Array() {
if (this.output instanceof ByteArrayOutputStream) {
return this.output.toUint8Array();
}
throw new Error('Cannot convert to Uint8Array: output stream is not ByteArrayOutputStream');
}
/**
* Gets the written data as Buffer (Node.js) or Uint8Array (browser)
*/
toBuffer() {
if (this.output instanceof ByteArrayOutputStream) {
return this.output.toBuffer();
}
throw new Error('Cannot convert to Buffer: output stream is not ByteArrayOutputStream');
}
}