1import { describe, expect, it } from "vite-plus/test";
2
3import { readImageDimensions } from "./imageDimensions.ts";
4
5function bytes(...parts: ReadonlyArray<number | string | ReadonlyArray<number>>): Uint8Array {
6 const out: number[] = [];
7 for (const part of parts) {
8 if (typeof part === "string") for (const c of part) out.push(c.charCodeAt(0));
9 else if (typeof part === "number") out.push(part);
10 else out.push(...part);
11 }
12 return Uint8Array.from(out);
13}
14
15const u32 = (n: number) => [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff];
16const u16 = (n: number) => [(n >>> 8) & 0xff, n & 0xff];
17const u16le = (n: number) => [n & 0xff, (n >>> 8) & 0xff];
18
19describe("readImageDimensions", () => {
20 it("reads a PNG IHDR", () => {
21 const png = bytes(
22 [0x89],
23 "PNG",
24 [0x0d, 0x0a, 0x1a, 0x0a],
25 u32(13),
26 "IHDR",
27 u32(1600),
28 u32(900),
29 );
30 expect(readImageDimensions(png)).toEqual({ width: 1600, height: 900 });
31 });
32
33 it("reads a GIF logical screen", () => {
34 expect(readImageDimensions(bytes("GIF89a", u16le(320), u16le(240)))).toEqual({
35 width: 320,
36 height: 240,
37 });
38 });
39
40 it("reads a JPEG start-of-frame after an APP segment", () => {
41 const app1 = bytes([0xff, 0xe1], u16(2 + 6), "Exif\0\0");
42 const sof0 = bytes([0xff, 0xc0], u16(17), [8], u16(1400), u16(720));
43 expect(readImageDimensions(bytes([0xff, 0xd8], [...app1], [...sof0]))).toEqual({
44 width: 720,
45 height: 1400,
46 });
47 });
48
49 it("swaps the axes for a JPEG whose EXIF orientation rotates it 90 degrees", () => {
50 // Big-endian TIFF with one IFD0 entry: tag 0x0112 (orientation), SHORT, count 1, value 6.
51 const tiff = [
52 0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08, 0x00, 0x01, 0x01, 0x12, 0x00, 0x03, 0x00,
53 0x00, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
54 ];
55 const exif = ["Exif\0\0", tiff] as const;
56 const app1 = bytes([0xff, 0xe1], u16(2 + 6 + tiff.length), ...exif);
57 const sof0 = bytes([0xff, 0xc0], u16(17), [8], u16(3024), u16(4032));
58 expect(readImageDimensions(bytes([0xff, 0xd8], [...app1], [...sof0]))).toEqual({
59 width: 3024,
60 height: 4032,
61 });
62 // A later XMP APP1 segment must not clear the rotation.
63 const xmp = bytes([0xff, 0xe1], u16(2 + 29), "http://ns.adobe.com/xap/1.0/\0");
64 expect(readImageDimensions(bytes([0xff, 0xd8], [...app1], [...xmp], [...sof0]))).toEqual({
65 width: 3024,
66 height: 4032,
67 });
68 // Orientation 1 leaves the frame size alone.
69 const upright = [...tiff];
70 upright[19] = 0x01;
71 const app1Upright = bytes([0xff, 0xe1], u16(2 + 6 + upright.length), "Exif\0\0", upright);
72 expect(readImageDimensions(bytes([0xff, 0xd8], [...app1Upright], [...sof0]))).toEqual({
73 width: 4032,
74 height: 3024,
75 });
76 });
77
78 it("steps over standalone TEM and restart markers", () => {
79 const sof0 = bytes([0xff, 0xc0], u16(17), [8], u16(10), u16(20));
80 expect(readImageDimensions(bytes([0xff, 0xd8], [0xff, 0x01], [0xff, 0xd3], [...sof0]))).toEqual(
81 { width: 20, height: 10 },
82 );
83 });
84
85 it("does not mistake a Huffman table marker for a frame", () => {
86 const dht = bytes([0xff, 0xc4], u16(4), [0, 0]);
87 const sof2 = bytes([0xff, 0xc2], u16(17), [8], u16(10), u16(20));
88 expect(readImageDimensions(bytes([0xff, 0xd8], [...dht], [...sof2]))).toEqual({
89 width: 20,
90 height: 10,
91 });
92 });
93
94 it("reads each WebP container flavour", () => {
95 const riff = (chunk: string, body: ReadonlyArray<number>) =>
96 bytes("RIFF", u32(0), "WEBP", chunk, u32(body.length), body);
97 // VP8: frame tag (3), start code (3), then 14-bit width and height.
98 expect(
99 readImageDimensions(riff("VP8 ", [0, 0, 0, 0x9d, 0x01, 0x2a, ...u16le(800), ...u16le(600)])),
100 ).toEqual({ width: 800, height: 600 });
101 // VP8L: signature 0x2f, then width-1 (14 bits) and height-1 (14 bits) packed LE.
102 const packed = (800 - 1) | ((600 - 1) << 14);
103 expect(
104 readImageDimensions(
105 riff("VP8L", [
106 0x2f,
107 packed & 0xff,
108 (packed >>> 8) & 0xff,
109 (packed >>> 16) & 0xff,
110 (packed >>> 24) & 0xff,
111 ]),
112 ),
113 ).toEqual({ width: 800, height: 600 });
114 // VP8X: flags (4), then 24-bit width-1 and height-1.
115 expect(
116 readImageDimensions(
117 riff("VP8X", [
118 0,
119 0,
120 0,
121 0,
122 799 & 0xff,
123 (799 >> 8) & 0xff,
124 0,
125 599 & 0xff,
126 (599 >> 8) & 0xff,
127 0,
128 ]),
129 ),
130 ).toEqual({ width: 800, height: 600 });
131 });
132
133 it("returns null for unsupported, truncated, or zero-sized input", () => {
134 expect(readImageDimensions(bytes("<svg xmlns='http://www.w3.org/2000/svg'/>"))).toBeNull();
135 expect(readImageDimensions(bytes([0x89], "PNG"))).toBeNull();
136 expect(readImageDimensions(bytes("GIF89a", u16le(0), u16le(240)))).toBeNull();
137 expect(readImageDimensions(bytes([0xff, 0xd8], [0xff, 0xd9]))).toBeNull();
138 expect(readImageDimensions(new Uint8Array())).toBeNull();
139 });
140});