154 lines · 5.5 KB · TypeScript
RawDownload
/** * Reads pixel dimensions from the header bytes of a PNG, JPEG, GIF, or WebP * file so a client can reserve the exact box before the bytes arrive. Any * other format, a truncated header, or a malformed file yields null; callers * fall back to measuring after decode. */export interface ImageDimensions {  readonly width: number;  readonly height: number;} /** * Enough for every supported header. A JPEG's frame header can sit behind * several 64 KiB metadata segments (EXIF, an ICC profile, XMP), so allow a * few of them before giving up. */export const IMAGE_DIMENSIONS_HEADER_BYTES = 256 * 1024; export function readImageDimensions(bytes: Uint8Array): ImageDimensions | null {  const dimensions = readPng(bytes) ?? readGif(bytes) ?? readWebp(bytes) ?? readJpeg(bytes);  return dimensions && dimensions.width > 0 && dimensions.height > 0 ? dimensions : null;} const view = (bytes: Uint8Array) => new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); function readPng(bytes: Uint8Array): ImageDimensions | null {  if (bytes.length < 24) return null;  if (    bytes[0] !== 0x89 ||    bytes[1] !== 0x50 ||    bytes[2] !== 0x4e ||    bytes[3] !== 0x47 ||    bytes[12] !== 0x49 ||    bytes[13] !== 0x48 ||    bytes[14] !== 0x44 ||    bytes[15] !== 0x52  ) {    return null;  }  const data = view(bytes);  return { width: data.getUint32(16), height: data.getUint32(20) };} function readGif(bytes: Uint8Array): ImageDimensions | null {  if (bytes.length < 10) return null;  if (bytes[0] !== 0x47 || bytes[1] !== 0x49 || bytes[2] !== 0x46 || bytes[3] !== 0x38) return null;  const data = view(bytes);  return { width: data.getUint16(6, true), height: data.getUint16(8, true) };} function readWebp(bytes: Uint8Array): ImageDimensions | null {  if (bytes.length < 16) return null;  if (    bytes[0] !== 0x52 ||    bytes[1] !== 0x49 ||    bytes[2] !== 0x46 ||    bytes[3] !== 0x46 ||    bytes[8] !== 0x57 ||    bytes[9] !== 0x45 ||    bytes[10] !== 0x42 ||    bytes[11] !== 0x50  ) {    return null;  }  const data = view(bytes);  const chunk = String.fromCharCode(bytes[12]!, bytes[13]!, bytes[14]!, bytes[15]!);  switch (chunk) {    case "VP8 ":      if (bytes.length < 30) return null;      // Lossy: 14-bit dimensions after the 3-byte frame tag and 3-byte start code.      return {        width: data.getUint16(26, true) & 0x3fff,        height: data.getUint16(28, true) & 0x3fff,      };    case "VP8L": {      if (bytes.length < 25) return null;      // Lossless: width-1 in bits 0-13 and height-1 in bits 14-27 of the      // 32 bits after the signature byte.      const packed = data.getUint32(21, true);      return { width: (packed & 0x3fff) + 1, height: ((packed >>> 14) & 0x3fff) + 1 };    }    case "VP8X":      if (bytes.length < 30) return null;      // Extended: 24-bit canvas dimensions minus one.      return {        width: (bytes[24]! | (bytes[25]! << 8) | (bytes[26]! << 16)) + 1,        height: (bytes[27]! | (bytes[28]! << 8) | (bytes[29]! << 16)) + 1,      };    default:      return null;  }} function readJpeg(bytes: Uint8Array): ImageDimensions | null {  if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return null;  const data = view(bytes);  let offset = 2;  let rotated = false;  while (offset + 9 <= bytes.length) {    if (bytes[offset] !== 0xff) return null;    const marker = bytes[offset + 1]!;    // Padding bytes between segments.    if (marker === 0xff) {      offset += 1;      continue;    }    // Start-of-frame markers carry the dimensions; skip the arithmetic-coding    // and Huffman-table markers that share the range.    if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {      const height = data.getUint16(offset + 5);      const width = data.getUint16(offset + 7);      return rotated ? { width: height, height: width } : { width, height };    }    if (marker === 0xd9 || marker === 0xda) return null;    // TEM and the restart markers stand alone, with no length field.    if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {      offset += 2;      continue;    }    const length = data.getUint16(offset + 2);    // Viewers apply the EXIF orientation before display, so a phone photo    // stored on its side takes the swapped size on screen.    if (marker === 0xe1 && !rotated) {      rotated = exifOrientationSwapsAxes(bytes, offset + 4, offset + 2 + length);    }    offset += 2 + length;  }  return null;} /** Whether EXIF orientation 5-8 (a 90° rotation) applies. `start` is the APP1 payload. */function exifOrientationSwapsAxes(bytes: Uint8Array, start: number, end: number): boolean {  end = Math.min(end, bytes.length);  // "Exif\0\0" then a TIFF header: byte order, 0x2a, and the IFD0 offset.  if (end - start < 14 || String.fromCharCode(...bytes.subarray(start, start + 4)) !== "Exif") {    return false;  }  const tiff = start + 6;  const data = view(bytes);  const littleEndian = bytes[tiff] === 0x49 && bytes[tiff + 1] === 0x49;  if (!littleEndian && !(bytes[tiff] === 0x4d && bytes[tiff + 1] === 0x4d)) return false;  const ifd = tiff + data.getUint32(tiff + 4, littleEndian);  if (ifd + 2 > end) return false;  const entries = data.getUint16(ifd, littleEndian);  for (let i = 0; i < entries; i += 1) {    const entry = ifd + 2 + i * 12;    if (entry + 12 > end) return false;    if (data.getUint16(entry, littleEndian) === 0x0112) {      const orientation = data.getUint16(entry + 8, littleEndian);      return orientation >= 5 && orientation <= 8;    }  }  return false;}