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 */
7export 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 */
17export const IMAGE_DIMENSIONS_HEADER_BYTES = 256 * 1024;
18
19export 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
24const view = (bytes: Uint8Array) => new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
25
26function 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
44function 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
51function 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
94function 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. */
132function 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}