| 1 | /** |
| 2 | * Reads pixel dimensions from the header bytes of a PNG, JPEG, GIF, or WebP |
| 3 | * file so a client can reserve the exact box before the bytes arrive. Any |
| 4 | * other format, a truncated header, or a malformed file yields null; callers |
| 5 | * fall back to measuring after decode. |
| 6 | */ |
| 7 | export interface ImageDimensions { |
| 8 | readonly width: number; |
| 9 | readonly height: number; |
| 10 | } |
| 11 | |
| 12 | /** |
| 13 | * Enough for every supported header. A JPEG's frame header can sit behind |
| 14 | * several 64 KiB metadata segments (EXIF, an ICC profile, XMP), so allow a |
| 15 | * few of them before giving up. |
| 16 | */ |
| 17 | export const IMAGE_DIMENSIONS_HEADER_BYTES = 256 * 1024; |
| 18 | |
| 19 | export function readImageDimensions(bytes: Uint8Array): ImageDimensions | null { |
| 20 | const dimensions = readPng(bytes) ?? readGif(bytes) ?? readWebp(bytes) ?? readJpeg(bytes); |
| 21 | return dimensions && dimensions.width > 0 && dimensions.height > 0 ? dimensions : null; |
| 22 | } |
| 23 | |
| 24 | const view = (bytes: Uint8Array) => new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); |
| 25 | |
| 26 | function readPng(bytes: Uint8Array): ImageDimensions | null { |
| 27 | if (bytes.length < 24) return null; |
| 28 | if ( |
| 29 | bytes[0] !== 0x89 || |
| 30 | bytes[1] !== 0x50 || |
| 31 | bytes[2] !== 0x4e || |
| 32 | bytes[3] !== 0x47 || |
| 33 | bytes[12] !== 0x49 || |
| 34 | bytes[13] !== 0x48 || |
| 35 | bytes[14] !== 0x44 || |
| 36 | bytes[15] !== 0x52 |
| 37 | ) { |
| 38 | return null; |
| 39 | } |
| 40 | const data = view(bytes); |
| 41 | return { width: data.getUint32(16), height: data.getUint32(20) }; |
| 42 | } |
| 43 | |
| 44 | function readGif(bytes: Uint8Array): ImageDimensions | null { |
| 45 | if (bytes.length < 10) return null; |
| 46 | if (bytes[0] !== 0x47 || bytes[1] !== 0x49 || bytes[2] !== 0x46 || bytes[3] !== 0x38) return null; |
| 47 | const data = view(bytes); |
| 48 | return { width: data.getUint16(6, true), height: data.getUint16(8, true) }; |
| 49 | } |
| 50 | |
| 51 | function readWebp(bytes: Uint8Array): ImageDimensions | null { |
| 52 | if (bytes.length < 16) return null; |
| 53 | if ( |
| 54 | bytes[0] !== 0x52 || |
| 55 | bytes[1] !== 0x49 || |
| 56 | bytes[2] !== 0x46 || |
| 57 | bytes[3] !== 0x46 || |
| 58 | bytes[8] !== 0x57 || |
| 59 | bytes[9] !== 0x45 || |
| 60 | bytes[10] !== 0x42 || |
| 61 | bytes[11] !== 0x50 |
| 62 | ) { |
| 63 | return null; |
| 64 | } |
| 65 | const data = view(bytes); |
| 66 | const chunk = String.fromCharCode(bytes[12]!, bytes[13]!, bytes[14]!, bytes[15]!); |
| 67 | switch (chunk) { |
| 68 | case "VP8 ": |
| 69 | if (bytes.length < 30) return null; |
| 70 | // Lossy: 14-bit dimensions after the 3-byte frame tag and 3-byte start code. |
| 71 | return { |
| 72 | width: data.getUint16(26, true) & 0x3fff, |
| 73 | height: data.getUint16(28, true) & 0x3fff, |
| 74 | }; |
| 75 | case "VP8L": { |
| 76 | if (bytes.length < 25) return null; |
| 77 | // Lossless: width-1 in bits 0-13 and height-1 in bits 14-27 of the |
| 78 | // 32 bits after the signature byte. |
| 79 | const packed = data.getUint32(21, true); |
| 80 | return { width: (packed & 0x3fff) + 1, height: ((packed >>> 14) & 0x3fff) + 1 }; |
| 81 | } |
| 82 | case "VP8X": |
| 83 | if (bytes.length < 30) return null; |
| 84 | // Extended: 24-bit canvas dimensions minus one. |
| 85 | return { |
| 86 | width: (bytes[24]! | (bytes[25]! << 8) | (bytes[26]! << 16)) + 1, |
| 87 | height: (bytes[27]! | (bytes[28]! << 8) | (bytes[29]! << 16)) + 1, |
| 88 | }; |
| 89 | default: |
| 90 | return null; |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | function readJpeg(bytes: Uint8Array): ImageDimensions | null { |
| 95 | if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return null; |
| 96 | const data = view(bytes); |
| 97 | let offset = 2; |
| 98 | let rotated = false; |
| 99 | while (offset + 9 <= bytes.length) { |
| 100 | if (bytes[offset] !== 0xff) return null; |
| 101 | const marker = bytes[offset + 1]!; |
| 102 | // Padding bytes between segments. |
| 103 | if (marker === 0xff) { |
| 104 | offset += 1; |
| 105 | continue; |
| 106 | } |
| 107 | // Start-of-frame markers carry the dimensions; skip the arithmetic-coding |
| 108 | // and Huffman-table markers that share the range. |
| 109 | if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) { |
| 110 | const height = data.getUint16(offset + 5); |
| 111 | const width = data.getUint16(offset + 7); |
| 112 | return rotated ? { width: height, height: width } : { width, height }; |
| 113 | } |
| 114 | if (marker === 0xd9 || marker === 0xda) return null; |
| 115 | // TEM and the restart markers stand alone, with no length field. |
| 116 | if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { |
| 117 | offset += 2; |
| 118 | continue; |
| 119 | } |
| 120 | const length = data.getUint16(offset + 2); |
| 121 | // Viewers apply the EXIF orientation before display, so a phone photo |
| 122 | // stored on its side takes the swapped size on screen. |
| 123 | if (marker === 0xe1 && !rotated) { |
| 124 | rotated = exifOrientationSwapsAxes(bytes, offset + 4, offset + 2 + length); |
| 125 | } |
| 126 | offset += 2 + length; |
| 127 | } |
| 128 | return null; |
| 129 | } |
| 130 | |
| 131 | /** Whether EXIF orientation 5-8 (a 90° rotation) applies. `start` is the APP1 payload. */ |
| 132 | function exifOrientationSwapsAxes(bytes: Uint8Array, start: number, end: number): boolean { |
| 133 | end = Math.min(end, bytes.length); |
| 134 | // "Exif\0\0" then a TIFF header: byte order, 0x2a, and the IFD0 offset. |
| 135 | if (end - start < 14 || String.fromCharCode(...bytes.subarray(start, start + 4)) !== "Exif") { |
| 136 | return false; |
| 137 | } |
| 138 | const tiff = start + 6; |
| 139 | const data = view(bytes); |
| 140 | const littleEndian = bytes[tiff] === 0x49 && bytes[tiff + 1] === 0x49; |
| 141 | if (!littleEndian && !(bytes[tiff] === 0x4d && bytes[tiff + 1] === 0x4d)) return false; |
| 142 | const ifd = tiff + data.getUint32(tiff + 4, littleEndian); |
| 143 | if (ifd + 2 > end) return false; |
| 144 | const entries = data.getUint16(ifd, littleEndian); |
| 145 | for (let i = 0; i < entries; i += 1) { |
| 146 | const entry = ifd + 2 + i * 12; |
| 147 | if (entry + 12 > end) return false; |
| 148 | if (data.getUint16(entry, littleEndian) === 0x0112) { |
| 149 | const orientation = data.getUint16(entry + 8, littleEndian); |
| 150 | return orientation >= 5 && orientation <= 8; |
| 151 | } |
| 152 | } |
| 153 | return false; |
| 154 | } |