import assert from 'node:assert'; import { Buffer } from 'node:buffer'; import type { Size } from '../types'; import { ifInNode } from '../utils'; import getPhoton from './get-photon'; import getSharp from './get-sharp'; export interface ImageInfo extends Size {} const isJpegStartOfFrameMarker = (marker: number): boolean => marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc; function jpegInfoFromBuffer(imageBuffer: Buffer): ImageInfo { let offset = 2; while (offset < imageBuffer.length) { if (imageBuffer[offset] !== 0xff) { offset += 1; continue; } while (offset < imageBuffer.length && imageBuffer[offset] === 0xff) { offset += 1; } if (offset >= imageBuffer.length) break; const marker = imageBuffer[offset]; offset += 1; if (marker === 0xd9 || marker === 0xda) break; if ( marker === 0x01 || marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd7) ) { continue; } if (offset + 2 > imageBuffer.length) break; const segmentLength = imageBuffer.readUInt16BE(offset); if (segmentLength < 2 || offset + segmentLength > imageBuffer.length) break; if (isJpegStartOfFrameMarker(marker)) { if (segmentLength < 7) break; const height = imageBuffer.readUInt16BE(offset + 3); const width = imageBuffer.readUInt16BE(offset + 5); assert(width && height, 'Invalid image: cannot get width or height'); return { width, height }; } offset += segmentLength; } throw new Error('Invalid image: cannot get JPEG width or height'); } /** * Reads PNG/JPEG dimensions from the encoded header without decoding pixels. * This is intended for validating already-decoded dimension hints before an * image transform; full image validation remains the decoder's responsibility. */ export function encodedImageInfoOfBuffer(imageBuffer: Buffer): ImageInfo { if (isValidPNGImageBuffer(imageBuffer)) { const width = imageBuffer.readUInt32BE(16); const height = imageBuffer.readUInt32BE(20); assert(width && height, 'Invalid image: cannot get width or height'); return { width, height }; } if (isValidJPEGImageBuffer(imageBuffer)) { return jpegInfoFromBuffer(imageBuffer); } throw new Error('Invalid image: unsupported format'); } /** * Retrieves the dimensions of an image from a base64-encoded string * * @param imageBase64 - The base64-encoded image data * @returns A Promise that resolves to an object containing the width and height of the image * @throws Error if the image data is invalid */ export async function imageInfoOfBase64( imageBase64: string, ): Promise { const base64Data = imageBase64 .replace(/^data:image\/\w+;base64,/, '') .replace(/\s/g, ''); assert(base64Data, 'Invalid image: empty base64 data'); assert( /^[A-Za-z0-9+/]+={0,2}$/.test(base64Data) && base64Data.length % 4 !== 1, 'Invalid image: malformed base64 data', ); const imageBuffer = Buffer.from(base64Data, 'base64'); assert(isValidImageBuffer(imageBuffer), 'Invalid image: unsupported format'); if (ifInNode) { let metadata; try { const Sharp = await getSharp(); metadata = await Sharp(imageBuffer).metadata(); } catch (error) { throw new Error( `Invalid image: failed to decode base64 data (${error instanceof Error ? error.message : String(error)})`, { cause: error }, ); } assert( metadata.width && metadata.height, 'Invalid image: cannot get width or height', ); return { width: metadata.width, height: metadata.height }; } const { PhotonImage } = await getPhoton(); let image: ReturnType; try { image = PhotonImage.new_from_base64(base64Data); } catch (error) { throw new Error( `Invalid image: failed to decode base64 data (${error instanceof Error ? error.message : String(error)})`, { cause: error }, ); } const width = image.get_width(); const height = image.get_height(); image.free(); assert(width && height, 'Invalid image: cannot get width or height'); return { width, height }; } /** * Check if the Buffer is a valid PNG image * @param buffer The Buffer to check * @returns true if the Buffer is a valid PNG image, otherwise false */ export function isValidPNGImageBuffer(buffer: Buffer): boolean { // A PNG consists of the 8-byte signature followed by chunks and must end // with the 12-byte IEND chunk. Checking only the signature lets truncated // screenshots through, which then fail when sent to an image model. if (!buffer || buffer.length < 20) { return false; } // Check PNG signature (8 bytes): 89 50 4E 47 0D 0A 1A 0A // This is more robust than just checking the first 4 bytes const isPNG = buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47 && buffer[4] === 0x0d && buffer[5] === 0x0a && buffer[6] === 0x1a && buffer[7] === 0x0a; if (!isPNG) { return false; } // IEND has a zero-length payload and a fixed CRC (AE 42 60 82). It must be // the final chunk in a conforming PNG. const pngIendChunk = Buffer.from([ 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, ]); return buffer .subarray(buffer.length - pngIendChunk.length) .equals(pngIendChunk); } /** * Check if the Buffer is a valid JPEG image * @param buffer The Buffer to check * @returns true if the Buffer is a valid JPEG image, otherwise false */ export function isValidJPEGImageBuffer(buffer: Buffer): boolean { if (!buffer || buffer.length < 3) { return false; } // Check JPEG signature (3 bytes): FF D8 FF return buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff; } /** * Check if the Buffer is a valid image (PNG or JPEG) * @param buffer The Buffer to check * @returns true if the Buffer is a valid PNG or JPEG image, otherwise false */ export function isValidImageBuffer(buffer: Buffer): boolean { return isValidPNGImageBuffer(buffer) || isValidJPEGImageBuffer(buffer); } export interface ValidateScreenshotBufferOptions { label: string; minBufferSize?: number; } export function validateScreenshotBuffer( screenshotBuffer: Buffer | undefined, { label, minBufferSize = 0 }: ValidateScreenshotBufferOptions, ): asserts screenshotBuffer is Buffer { const bufferSize = screenshotBuffer?.length ?? 0; if (!screenshotBuffer || bufferSize === 0) { throw new Error( `${label} validation failed: buffer size ${bufferSize} bytes`, ); } if (!isValidImageBuffer(screenshotBuffer)) { throw new Error(`${label} buffer has invalid image format`); } if (minBufferSize > 0 && bufferSize < minBufferSize) { throw new Error( `${label} validation failed: buffer size ${bufferSize} bytes (minimum: ${minBufferSize})`, ); } }