UNPKG

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.

865 lines (864 loc) 36.6 kB
/** * GIF Reader Implementation * * Parses existing GIF files and extracts frames, metadata, and color information. */ import { GifValidationError, DisposalMethod, } from './types.js'; import { decompressLZW } from './lzw.js'; /** * Reads and parses GIF files */ export class GifReader { constructor(data) { this.position = 0; this.info = null; this.frames = []; this.metadata = { extensions: [], comments: [], hasInterlacedFrames: false, hasLocalColorTables: false, hasTransparency: false, technicalDetails: { totalDataSize: 0, averageFrameSize: 0, }, }; this.data = data; this.validateHeader(); } /** * Validates GIF header */ validateHeader() { if (this.data.length < 6) { throw new GifValidationError('Invalid GIF: too short'); } // Check for common non-GIF file types const firstBytes = this.data.slice(0, 4); const firstChars = String.fromCharCode(...firstBytes); if (firstChars.startsWith('<')) { throw new GifValidationError('File appears to be HTML/XML, not a GIF'); } if (firstChars.includes('PNG')) { throw new GifValidationError('File appears to be a PNG, not a GIF'); } if (firstBytes[0] === 0xff && firstBytes[1] === 0xd8) { throw new GifValidationError('File appears to be a JPEG, not a GIF'); } const signature = String.fromCharCode(...this.data.slice(0, 3)); const version = String.fromCharCode(...this.data.slice(3, 6)); if (signature !== 'GIF') { // Provide more diagnostic information const hexBytes = Array.from(this.data.slice(0, Math.min(16, this.data.length))) .map(b => `0x${b.toString(16).padStart(2, '0')}`) .join(' '); throw new GifValidationError(`Invalid GIF signature: expected 'GIF', got '${signature}'. ` + `First 16 bytes: ${hexBytes}`); } if (version !== '87a' && version !== '89a') { throw new GifValidationError(`Unsupported GIF version: ${version}`); } this.position = 6; } /** * Reads a byte from the data */ readByte() { if (this.position >= this.data.length) { throw new GifValidationError('Unexpected end of GIF data'); } return this.data[this.position++]; } /** * Reads a 16-bit little-endian value */ read16() { const low = this.readByte(); const high = this.readByte(); return low | (high << 8); } /** * Reads multiple bytes */ readBytes(count) { if (this.position + count > this.data.length) { throw new GifValidationError('Unexpected end of GIF data'); } const result = this.data.slice(this.position, this.position + count); this.position += count; return result; } /** * Skips data sub-blocks */ skipDataSubBlocks() { let blockSize = this.readByte(); while (blockSize > 0) { this.position += blockSize; blockSize = this.readByte(); } } /** * Reads data sub-blocks and returns combined data */ readDataSubBlocks() { const blocks = []; let totalLength = 0; let blockSize = this.readByte(); while (blockSize > 0) { const blockData = this.readBytes(blockSize); blocks.push(blockData); totalLength += blockSize; blockSize = this.readByte(); } // Combine all blocks const result = new Uint8Array(totalLength); let offset = 0; for (const block of blocks) { result.set(block, offset); offset += block.length; } return result; } /** * Creates ImageData from index data and color table */ createFrameImageData(indexData, width, height, colorTable, transparentIndex, interlaced) { const rgba = new Uint8ClampedArray(width * height * 4); for (let i = 0; i < indexData.length && i < width * height; i++) { const colorIndex = indexData[i]; const pixelIndex = interlaced ? this.deinterlaceIndex(i, width, height) : i; const rgbaIndex = pixelIndex * 4; if (colorIndex === transparentIndex) { // Transparent pixel rgba[rgbaIndex] = 0; rgba[rgbaIndex + 1] = 0; rgba[rgbaIndex + 2] = 0; rgba[rgbaIndex + 3] = 0; } else if (colorTable && colorIndex * 3 + 2 < colorTable.length) { // Use color from table rgba[rgbaIndex] = colorTable[colorIndex * 3]; rgba[rgbaIndex + 1] = colorTable[colorIndex * 3 + 1]; rgba[rgbaIndex + 2] = colorTable[colorIndex * 3 + 2]; rgba[rgbaIndex + 3] = 255; } else { // Default to black rgba[rgbaIndex] = 0; rgba[rgbaIndex + 1] = 0; rgba[rgbaIndex + 2] = 0; rgba[rgbaIndex + 3] = 255; } } return { data: rgba, width, height, }; } /** * Converts interlaced index to actual pixel position */ deinterlaceIndex(index, width, height) { const x = index % width; const y = Math.floor(index / width); // GIF interlacing pattern: // Pass 1: Every 8th row, starting with row 0 // Pass 2: Every 8th row, starting with row 4 // Pass 3: Every 4th row, starting with row 2 // Pass 4: Every 2nd row, starting with row 1 let actualY; if (y < Math.ceil(height / 8)) { actualY = y * 8; } else if (y < Math.ceil(height / 8) + Math.ceil((height - 4) / 8)) { actualY = (y - Math.ceil(height / 8)) * 8 + 4; } else if (y < Math.ceil(height / 8) + Math.ceil((height - 4) / 8) + Math.ceil((height - 2) / 4)) { actualY = (y - Math.ceil(height / 8) - Math.ceil((height - 4) / 8)) * 4 + 2; } else { actualY = (y - Math.ceil(height / 8) - Math.ceil((height - 4) / 8) - Math.ceil((height - 2) / 4)) * 2 + 1; } return actualY * width + x; } /** * Converts interlaced Y coordinate to actual Y coordinate */ deinterlaceY(y, height) { if (y < Math.ceil(height / 8)) { return y * 8; } else if (y < Math.ceil(height / 8) + Math.ceil((height - 4) / 8)) { return (y - Math.ceil(height / 8)) * 8 + 4; } else if (y < Math.ceil(height / 8) + Math.ceil((height - 4) / 8) + Math.ceil((height - 2) / 4)) { return (y - Math.ceil(height / 8) - Math.ceil((height - 4) / 8)) * 4 + 2; } else { return ((y - Math.ceil(height / 8) - Math.ceil((height - 4) / 8) - Math.ceil((height - 2) / 4)) * 2 + 1); } } /** * Clears a frame area to background color */ clearFrameArea(canvas, left, top, width, height, globalColorTable, backgroundColorIndex, canvasWidth) { let bgR = 0, bgG = 0, bgB = 0, bgA = 0; if (globalColorTable && backgroundColorIndex < globalColorTable.length / 3) { bgR = globalColorTable[backgroundColorIndex * 3]; bgG = globalColorTable[backgroundColorIndex * 3 + 1]; bgB = globalColorTable[backgroundColorIndex * 3 + 2]; bgA = 255; } for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const canvasX = left + x; const canvasY = top + y; if (canvasX >= 0 && canvasX < canvasWidth && canvasY >= 0) { const canvasIndex = (canvasY * canvasWidth + canvasX) * 4; if (canvasIndex < canvas.length) { canvas[canvasIndex] = bgR; canvas[canvasIndex + 1] = bgG; canvas[canvasIndex + 2] = bgB; canvas[canvasIndex + 3] = bgA; } } } } } /** * Composites a frame onto the canvas */ compositeFrame(canvas, indexData, left, top, width, height, colorTable, transparentIndex, interlaced, canvasWidth, canvasHeight) { for (let i = 0; i < indexData.length && i < width * height; i++) { const colorIndex = indexData[i]; // Skip transparent pixels if (colorIndex === transparentIndex) { continue; } // Get pixel position within the frame const frameX = i % width; const frameY = Math.floor(i / width); // Apply interlacing if needed const actualY = interlaced ? this.deinterlaceY(frameY, height) : frameY; // Calculate canvas position const canvasX = left + frameX; const canvasY = top + actualY; // Check bounds if (canvasX >= 0 && canvasX < canvasWidth && canvasY >= 0 && canvasY < canvasHeight) { const canvasIndex = (canvasY * canvasWidth + canvasX) * 4; if (canvasIndex < canvas.length) { if (colorTable && colorIndex * 3 + 2 < colorTable.length) { // Use color from table canvas[canvasIndex] = colorTable[colorIndex * 3]; canvas[canvasIndex + 1] = colorTable[colorIndex * 3 + 1]; canvas[canvasIndex + 2] = colorTable[colorIndex * 3 + 2]; canvas[canvasIndex + 3] = 255; } else { // Default to black canvas[canvasIndex] = 0; canvas[canvasIndex + 1] = 0; canvas[canvasIndex + 2] = 0; canvas[canvasIndex + 3] = 255; } } } } } /** * Parses the GIF and extracts all information */ parse() { if (this.info) { return this.info; } // Read logical screen descriptor const width = this.read16(); const height = this.read16(); const packed = this.readByte(); const backgroundColorIndex = this.readByte(); const pixelAspectRatio = this.readByte(); const globalColorTableFlag = (packed & 0x80) !== 0; const colorResolution = ((packed & 0x70) >> 4) + 1; const sortFlag = (packed & 0x08) !== 0; const globalColorTableSize = 2 << (packed & 0x07); let globalColorTable; if (globalColorTableFlag) { globalColorTable = this.readBytes(globalColorTableSize * 3); } let loops = 0; let frameCount = 0; let totalDuration = 0; // Parse data stream while (this.position < this.data.length) { const separator = this.readByte(); if (separator === 0x21) { // Extension const label = this.readByte(); if (label === 0xff) { // Application extension const blockSize = this.readByte(); const appData = this.readBytes(blockSize); // Check for Netscape extension (animation info) if (blockSize === 11) { const identifier = String.fromCharCode(...appData.slice(0, 8)); if (identifier === 'NETSCAPE') { this.metadata.extensions.push('Netscape 2.0 (Animation)'); const subBlockSize = this.readByte(); if (subBlockSize === 3) { this.readByte(); // Loop indicator loops = this.read16(); } this.readByte(); // Block terminator continue; // Skip the general skipDataSubBlocks call } } // Check for known application extensions if (blockSize === 11) { const identifier = String.fromCharCode(...appData); if (identifier === 'XMP DataXMP') { // Adobe XMP metadata this.metadata.extensions.push('XMP Metadata'); const xmpData = this.readDataSubBlocks(); if (xmpData.length > 0) { try { this.metadata.xmpData = String.fromCharCode(...xmpData); } catch (_a) { // If XMP data is not valid text, just note its presence this.metadata.xmpData = `[Binary XMP data, ${xmpData.length} bytes]`; } } continue; } else if (identifier === 'ICCRGBG1012' || identifier.startsWith('ICCRGBG1')) { // ICC Color Profile this.metadata.extensions.push('ICC Color Profile'); this.skipDataSubBlocks(); continue; } } // Check for other common extensions if (blockSize >= 8) { const identifier = String.fromCharCode(...appData.slice(0, 8)); if (identifier === 'MAGPIE01' || identifier === 'MAGPIE02') { // MAGPIE extension (some image editors) this.metadata.extensions.push(`MAGPIE Extension (${identifier})`); this.skipDataSubBlocks(); continue; } else if (identifier.startsWith('ADOBE')) { // Various Adobe extensions this.metadata.extensions.push(`Adobe Extension (${identifier})`); this.skipDataSubBlocks(); continue; } } // Check for animation extensions if (blockSize >= 11) { const identifier = String.fromCharCode(...appData.slice(0, 11)); if (identifier === 'ANIMEXTS1.0') { // Animation extensions this.metadata.extensions.push('Animation Extensions 1.0'); this.skipDataSubBlocks(); continue; } } // Record any other application extension if (blockSize > 0) { try { const identifier = String.fromCharCode(...appData.slice(0, Math.min(blockSize, 16))); this.metadata.extensions.push(`Application Extension (${identifier})`); } catch (_b) { this.metadata.extensions.push(`Application Extension (binary, ${blockSize} bytes)`); } } // For other application extensions, skip the remaining data this.skipDataSubBlocks(); } else if (label === 0xf9) { // Graphics control extension const blockSize = this.readByte(); if (blockSize === 4) { const packed = this.readByte(); const delay = this.read16() * 10; // Convert to milliseconds const transparentIndex = this.readByte(); this.readByte(); // Block terminator // Check for transparency if (transparentIndex !== 0 && (packed & 0x01) !== 0) { this.metadata.hasTransparency = true; } totalDuration += delay; } else { this.position += blockSize; this.readByte(); // Block terminator } } else if (label === 0xfe) { // Comment extension const commentData = this.readDataSubBlocks(); if (commentData.length > 0) { try { const comment = String.fromCharCode(...commentData); this.metadata.comments.push(comment); this.metadata.extensions.push('Comment Extension'); } catch (_c) { this.metadata.extensions.push('Comment Extension (binary data)'); } } } else if (label === 0x01) { // Plain text extension this.metadata.extensions.push('Plain Text Extension'); const blockSize = this.readByte(); this.readBytes(blockSize); // Skip text grid data this.skipDataSubBlocks(); // Skip text data } else if (label === 0xfd) { // Unknown extension (sometimes used for metadata) this.metadata.extensions.push('Unknown Extension (0xFD)'); this.skipDataSubBlocks(); } else if (label >= 0x80 && label <= 0xfc) { // Private use extensions this.metadata.extensions.push(`Private Extension (0x${label.toString(16).toUpperCase()})`); this.skipDataSubBlocks(); } else { // Skip any other unknown extensions gracefully this.metadata.extensions.push(`Unknown Extension (0x${label.toString(16).toUpperCase()})`); try { this.skipDataSubBlocks(); } catch (error) { // If skipping fails, log warning and continue parsing console.warn(`Failed to skip extension with label 0x${label.toString(16)}:`, error); break; // Exit parsing loop to prevent infinite loops } } } else if (separator === 0x2c) { // Image descriptor frameCount++; // Skip image descriptor and local color table this.position += 8; // left, top, width, height const packed = this.readByte(); const localColorTableFlag = (packed & 0x80) !== 0; const interlaceFlag = (packed & 0x40) !== 0; const localColorTableSize = localColorTableFlag ? 2 << (packed & 0x07) : 0; // Track metadata if (localColorTableFlag) { this.metadata.hasLocalColorTables = true; this.position += localColorTableSize * 3; } if (interlaceFlag) { this.metadata.hasInterlacedFrames = true; } // Skip LZW data this.readByte(); // LZW minimum code size this.skipDataSubBlocks(); } else if (separator === 0x3b) { // Trailer break; } else { // Provide more diagnostic information const char = String.fromCharCode(separator); const context = this.data.slice(Math.max(0, this.position - 10), this.position + 10); const contextStr = Array.from(context) .map(b => b >= 32 && b <= 126 ? String.fromCharCode(b) : `\\x${b.toString(16).padStart(2, '0')}`) .join(''); throw new GifValidationError(`Unknown separator: 0x${separator.toString(16)} ('${char}') at position ${this.position - 1}. ` + `Context: "${contextStr}". This may indicate the file is not a valid GIF or is corrupted.`); } } // Calculate technical details this.metadata.technicalDetails.totalDataSize = this.data.length; this.metadata.technicalDetails.averageFrameSize = frameCount > 0 ? Math.round(this.data.length / frameCount) : 0; this.info = { width, height, frameCount, loops, globalColorTable, backgroundColorIndex, pixelAspectRatio, colorResolution, sortFlag, duration: totalDuration, size: this.data.length, version: String.fromCharCode(...this.data.slice(3, 6)), metadata: { ...this.metadata }, }; return this.info; } /** * Gets basic information about the GIF */ getInfo() { return this.parse(); } /** * Extracts all frames from the GIF with proper canvas composition */ getFrames() { if (this.frames.length > 0) { return this.frames; } // Reset position to parse frames this.position = 6; // Read logical screen descriptor const width = this.read16(); const height = this.read16(); const packed = this.readByte(); const backgroundColorIndex = this.readByte(); this.readByte(); // pixel aspect ratio const globalColorTableFlag = (packed & 0x80) !== 0; const globalColorTableSize = 2 << (packed & 0x07); let globalColorTable; if (globalColorTableFlag) { globalColorTable = this.readBytes(globalColorTableSize * 3); } // Canvas for proper frame composition let canvas = new Uint8ClampedArray(width * height * 4); let previousCanvas = null; // Initialize canvas with background color if (globalColorTable && backgroundColorIndex < globalColorTable.length / 3) { const bgR = globalColorTable[backgroundColorIndex * 3]; const bgG = globalColorTable[backgroundColorIndex * 3 + 1]; const bgB = globalColorTable[backgroundColorIndex * 3 + 2]; for (let i = 0; i < canvas.length; i += 4) { canvas[i] = bgR; canvas[i + 1] = bgG; canvas[i + 2] = bgB; canvas[i + 3] = 255; } } else { // Default to transparent canvas.fill(0); } let currentDelay = 100; // Default delay let currentDisposal = DisposalMethod.DoNotDispose; let currentTransparentIndex = -1; // Parse data stream while (this.position < this.data.length) { const separator = this.readByte(); if (separator === 0x21) { // Extension const label = this.readByte(); if (label === 0xff) { // Application extension const blockSize = this.readByte(); const appData = this.readBytes(blockSize); // Check for known application extensions if (blockSize === 11) { const identifier = String.fromCharCode(...appData); if (identifier === 'XMP DataXMP') { // Adobe XMP metadata this.skipDataSubBlocks(); continue; } else if (identifier === 'ICCRGBG1012' || identifier.startsWith('ICCRGBG1')) { // ICC Color Profile this.skipDataSubBlocks(); continue; } } // Check for other common extensions if (blockSize >= 8) { const identifier = String.fromCharCode(...appData.slice(0, 8)); if (identifier === 'MAGPIE01' || identifier === 'MAGPIE02') { // MAGPIE extension (some image editors) this.skipDataSubBlocks(); continue; } else if (identifier.startsWith('ADOBE')) { // Various Adobe extensions this.skipDataSubBlocks(); continue; } } // Check for animation extensions if (blockSize >= 11) { const identifier = String.fromCharCode(...appData.slice(0, 11)); if (identifier === 'ANIMEXTS1.0') { // Animation extensions this.skipDataSubBlocks(); continue; } } // For other application extensions, skip the remaining data this.skipDataSubBlocks(); } else if (label === 0xf9) { // Graphics control extension const blockSize = this.readByte(); if (blockSize === 4) { const packedFlags = this.readByte(); currentDelay = this.read16() * 10; // Convert to milliseconds currentTransparentIndex = this.readByte(); // Parse disposal method const disposalFlag = (packedFlags & 0x1c) >> 2; switch (disposalFlag) { case 1: currentDisposal = DisposalMethod.DoNotDispose; break; case 2: currentDisposal = DisposalMethod.RestoreToBackground; break; case 3: currentDisposal = DisposalMethod.RestoreToPrevious; break; default: currentDisposal = DisposalMethod.DoNotDispose; break; } // Check for transparent color flag if ((packedFlags & 0x01) === 0) { currentTransparentIndex = -1; } this.readByte(); // Block terminator } else { this.position += blockSize; this.readByte(); // Block terminator } } else if (label === 0xfe) { // Comment extension this.skipDataSubBlocks(); } else if (label === 0x01) { // Plain text extension const blockSize = this.readByte(); this.readBytes(blockSize); // Skip text grid data this.skipDataSubBlocks(); // Skip text data } else if (label === 0xfd) { // Unknown extension (sometimes used for metadata) this.skipDataSubBlocks(); } else if (label >= 0x80 && label <= 0xfc) { // Private use extensions this.skipDataSubBlocks(); } else { // Skip any other unknown extensions gracefully try { this.skipDataSubBlocks(); } catch (error) { // If skipping fails, log warning and continue parsing console.warn(`Failed to skip extension with label 0x${label.toString(16)}:`, error); break; // Exit parsing loop to prevent infinite loops } } } else if (separator === 0x2c) { // Image descriptor const frameLeft = this.read16(); const frameTop = this.read16(); const frameWidth = this.read16(); const frameHeight = this.read16(); const framePacked = this.readByte(); const localColorTableFlag = (framePacked & 0x80) !== 0; const interlaceFlag = (framePacked & 0x40) !== 0; const localColorTableSize = localColorTableFlag ? 2 << (framePacked & 0x07) : 0; let localColorTable; if (localColorTableFlag) { localColorTable = this.readBytes(localColorTableSize * 3); } // Use local color table if available, otherwise global const colorTable = localColorTable || globalColorTable; // Read LZW data const lzwMinimumCodeSize = this.readByte(); const compressedData = this.readDataSubBlocks(); try { // Handle disposal method from previous frame if (previousCanvas && currentDisposal === DisposalMethod.RestoreToPrevious) { canvas = new Uint8ClampedArray(previousCanvas); } else if (currentDisposal === DisposalMethod.RestoreToBackground) { // Clear the frame area to background this.clearFrameArea(canvas, frameLeft, frameTop, frameWidth, frameHeight, globalColorTable, backgroundColorIndex, width); } // Save current canvas state if needed for next frame if (currentDisposal === DisposalMethod.RestoreToPrevious) { previousCanvas = new Uint8ClampedArray(canvas); } // Decompress LZW data const indexData = decompressLZW(compressedData, lzwMinimumCodeSize); // Composite frame onto canvas this.compositeFrame(canvas, indexData, frameLeft, frameTop, frameWidth, frameHeight, colorTable, currentTransparentIndex, interlaceFlag, width, height); // Create frame object with full canvas const frameImageData = { data: new Uint8ClampedArray(canvas), width, height, }; const frame = { imageData: frameImageData, delay: currentDelay, disposal: currentDisposal, left: 0, top: 0, transparentIndex: currentTransparentIndex, }; this.frames.push(frame); // Reset for next frame currentDelay = 100; currentDisposal = DisposalMethod.DoNotDispose; currentTransparentIndex = -1; } catch (error) { console.error(`Failed to decompress frame ${this.frames.length + 1}:`, error); console.log(`Frame details: ${frameWidth}x${frameHeight}, LZW code size: ${lzwMinimumCodeSize}, Compressed data length: ${compressedData.length}`); // Create a placeholder frame with error information const errorFrame = { imageData: { data: new Uint8ClampedArray(frameWidth * frameHeight * 4).fill(255), width: frameWidth, height: frameHeight, }, delay: currentDelay, disposal: currentDisposal, left: frameLeft, top: frameTop, transparentIndex: currentTransparentIndex, }; this.frames.push(errorFrame); // Reset for next frame currentDelay = 100; currentDisposal = DisposalMethod.DoNotDispose; currentTransparentIndex = -1; } } else if (separator === 0x3b) { // Trailer break; } else { // Provide more diagnostic information const char = String.fromCharCode(separator); const context = this.data.slice(Math.max(0, this.position - 10), this.position + 10); const contextStr = Array.from(context) .map(b => b >= 32 && b <= 126 ? String.fromCharCode(b) : `\\x${b.toString(16).padStart(2, '0')}`) .join(''); throw new GifValidationError(`Unknown separator: 0x${separator.toString(16)} ('${char}') at position ${this.position - 1}. ` + `Context: "${contextStr}". This may indicate the file is not a valid GIF or is corrupted.`); } } return this.frames; } /** * Checks if the GIF is animated */ isAnimated() { const info = this.getInfo(); return info.frameCount > 1; } /** * Gets the dominant colors from the global color table */ getDominantColors(count = 5) { const info = this.getInfo(); if (!info.globalColorTable) { return []; } const colors = []; const colorTable = info.globalColorTable; for (let i = 0; i < Math.min(count, colorTable.length / 3); i++) { const offset = i * 3; colors.push({ red: colorTable[offset], green: colorTable[offset + 1], blue: colorTable[offset + 2], }); } return colors; } /** * Gets all colors from the global color table */ getAllColors() { const info = this.getInfo(); if (!info.globalColorTable) { return []; } const colors = []; const colorTable = info.globalColorTable; for (let i = 0; i < colorTable.length / 3; i++) { const offset = i * 3; colors.push({ red: colorTable[offset], green: colorTable[offset + 1], blue: colorTable[offset + 2], }); } return colors; } } /** * Convenience function to read GIF info from buffer */ export function readGifInfo(data) { const reader = new GifReader(data); return reader.getInfo(); } /** * Convenience function to check if data is a valid GIF */ export function isValidGif(data) { try { new GifReader(data); return true; } catch (_a) { return false; } }