1export interface ImageDimensions {
2 readonly width: number;
3 readonly height: number;
4}
5
6/** Allows several 64 KiB JPEG metadata segments before the dimensions header. */
7export const IMAGE_DIMENSIONS_HEADER_BYTES = 256 * 1024;
8
9export function readImageDimensions(bytes: Uint8Array): ImageDimensions | null {
10 const dimensions = readPng(bytes) ?? readGif(bytes) ?? readWebp(bytes) ?? readJpeg(bytes);
11 return dimensions && dimensions.width > 0 && dimensions.height > 0 ? dimensions : null;
12}
13
14const view = (bytes: Uint8Array) => new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
15
16function readPng(bytes: Uint8Array): ImageDimensions | null {
17 if (bytes.length < 24) return null;
18 if (
19 bytes[0] !== 0x89 ||
20 bytes[1] !== 0x50 ||
21 bytes[2] !== 0x4e ||
22 bytes[3] !== 0x47 ||
23 bytes[12] !== 0x49 ||
24 bytes[13] !== 0x48 ||
25 bytes[14] !== 0x44 ||
26 bytes[15] !== 0x52
27 ) {
28 return null;
29 }
30 const data = view(bytes);
31 return { width: data.getUint32(16), height: data.getUint32(20) };
32}
33
34function readGif(bytes: Uint8Array): ImageDimensions | null {
35 if (bytes.length < 10) return null;
36 if (bytes[0] !== 0x47 || bytes[1] !== 0x49 || bytes[2] !== 0x46 || bytes[3] !== 0x38) return null;
37 const data = view(bytes);
38 return { width: data.getUint16(6, true), height: data.getUint16(8, true) };
39}
40
41function readWebp(bytes: Uint8Array): ImageDimensions | null {
42 if (bytes.length < 16) return null;
43 if (
44 bytes[0] !== 0x52 ||
45 bytes[1] !== 0x49 ||
46 bytes[2] !== 0x46 ||
47 bytes[3] !== 0x46 ||
48 bytes[8] !== 0x57 ||
49 bytes[9] !== 0x45 ||
50 bytes[10] !== 0x42 ||
51 bytes[11] !== 0x50
52 ) {
53 return null;
54 }
55 const data = view(bytes);
56 const chunk = String.fromCharCode(bytes[12]!, bytes[13]!, bytes[14]!, bytes[15]!);
57 switch (chunk) {
58 case "VP8 ":
59 if (bytes.length < 30) return null;
60 return {
61 width: data.getUint16(26, true) & 0x3fff,
62 height: data.getUint16(28, true) & 0x3fff,
63 };
64 case "VP8L": {
65 if (bytes.length < 25) return null;
66 const packed = data.getUint32(21, true);
67 return { width: (packed & 0x3fff) + 1, height: ((packed >>> 14) & 0x3fff) + 1 };
68 }
69 case "VP8X":
70 if (bytes.length < 30) return null;
71 return {
72 width: (bytes[24]! | (bytes[25]! << 8) | (bytes[26]! << 16)) + 1,
73 height: (bytes[27]! | (bytes[28]! << 8) | (bytes[29]! << 16)) + 1,
74 };
75 default:
76 return null;
77 }
78}
79
80function readJpeg(bytes: Uint8Array): ImageDimensions | null {
81 if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return null;
82 const data = view(bytes);
83 let offset = 2;
84 let rotated = false;
85 while (offset + 9 <= bytes.length) {
86 if (bytes[offset] !== 0xff) return null;
87 const marker = bytes[offset + 1]!;
88 if (marker === 0xff) {
89 offset += 1;
90 continue;
91 }
92 // Exclude arithmetic-coding and Huffman-table markers from the frame-header range.
93 if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
94 const height = data.getUint16(offset + 5);
95 const width = data.getUint16(offset + 7);
96 return rotated ? { width: height, height: width } : { width, height };
97 }
98 if (marker === 0xd9 || marker === 0xda) return null;
99 // TEM and the restart markers stand alone, with no length field.
100 if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
101 offset += 2;
102 continue;
103 }
104 const length = data.getUint16(offset + 2);
105 // EXIF rotation swaps the displayed axes.
106 if (marker === 0xe1 && !rotated) {
107 rotated = exifOrientationSwapsAxes(bytes, offset + 4, offset + 2 + length);
108 }
109 offset += 2 + length;
110 }
111 return null;
112}
113
114function exifOrientationSwapsAxes(bytes: Uint8Array, start: number, end: number): boolean {
115 end = Math.min(end, bytes.length);
116 if (end - start < 14 || String.fromCharCode(...bytes.subarray(start, start + 4)) !== "Exif") {
117 return false;
118 }
119 const tiff = start + 6;
120 const data = view(bytes);
121 const littleEndian = bytes[tiff] === 0x49 && bytes[tiff + 1] === 0x49;
122 if (!littleEndian && !(bytes[tiff] === 0x4d && bytes[tiff + 1] === 0x4d)) return false;
123 const ifd = tiff + data.getUint32(tiff + 4, littleEndian);
124 if (ifd + 2 > end) return false;
125 const entries = data.getUint16(ifd, littleEndian);
126 for (let i = 0; i < entries; i += 1) {
127 const entry = ifd + 2 + i * 12;
128 if (entry + 12 > end) return false;
129 if (data.getUint16(entry, littleEndian) === 0x0112) {
130 const orientation = data.getUint16(entry + 8, littleEndian);
131 return orientation >= 5 && orientation <= 8;
132 }
133 }
134 return false;
135}