Skip to content

Commit 6471e3c

Browse files
UI/jpeg exif orientation (ggml-org#24196)
* ui: bake jpeg exif orientation into uploaded images stb_image in mtmd ignores exif metadata, so rotated smartphone photos reach the model with raw pixel orientation. The webui now reads the exif orientation tag at send time and feeds it into the existing capImageDataURLSize canvas pass: the browser applies the rotation when decoding, so capped images come out upright for free, and images under the cap threshold get a single plain redraw when orientation > 1. At most one re-encode ever happens per image. Upright jpegs with capping disabled pass through untouched, bit perfect. Adds jpeg-orientation.ts with a minimal exif parser working on a bounded base64 prefix (both endianness, returns 1 on any malformed input) and unit tests against handcrafted jpeg byte streams. * ui: move jpeg exif constants into lib/constants * ui: add browser test for jpeg orientation and capping Covers capImageDataURLSize end to end in chromium with real Pillow generated jpeg fixtures across exif orientations 1/3/5/6/8: upright quadrant colors checked pixel-wise, expected dimensions with and without capping, no orientation tag left in the output, and strict passthrough when nothing needs rewriting.
1 parent 88a3927 commit 6471e3c

6 files changed

Lines changed: 419 additions & 6 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**
2+
* JPEG and EXIF binary format constants for orientation parsing.
3+
*/
4+
5+
/** Bytes of file prefix to scan, the APP1 EXIF segment sits near the start */
6+
export const EXIF_SCAN_BYTE_LIMIT = 128 * 1024;
7+
8+
/** JPEG start of image marker */
9+
export const JPEG_SOI_MARKER = 0xffd8;
10+
11+
/** APP1 segment marker byte, carries the EXIF payload */
12+
export const APP1_MARKER = 0xe1;
13+
14+
/** Start of scan marker byte, compressed data begins and no EXIF follows */
15+
export const SOS_MARKER = 0xda;
16+
17+
/** "Exif" signature opening the APP1 payload, big endian uint32 */
18+
export const EXIF_SIGNATURE = 0x45786966;
19+
20+
/** TIFF byte order mark for little endian ("II") */
21+
export const TIFF_LITTLE_ENDIAN = 0x4949;
22+
23+
/** TIFF magic number following the byte order mark */
24+
export const TIFF_MAGIC = 42;
25+
26+
/** EXIF tag id holding the orientation value */
27+
export const EXIF_ORIENTATION_TAG = 0x0112;
28+
29+
/** Size in bytes of one IFD directory entry */
30+
export const IFD_ENTRY_SIZE = 12;

tools/ui/src/lib/services/chat.service.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ import type {
3434
import { modelsStore } from '$lib/stores/models.svelte';
3535
import { settingsStore } from '../stores/settings.svelte';
3636
import { capImageDataURLSize } from '../utils/cap-img-size';
37-
import { MEGAPIXELS_TO_PIXELS } from '$lib/constants/image-size';
3837

3938
function getAudioInputFormat(mimeType: string): AudioInputFormat {
4039
const normalizedMimeType = mimeType.trim().toLowerCase();
@@ -961,10 +960,11 @@ export class ChatService {
961960

962961
for (const image of imageFiles) {
963962
const maxImageResolution = settingsStore.getConfig(SETTINGS_KEYS.MAX_IMAGE_RESOLUTION);
964-
let base64Url = image.base64Url;
965-
if (maxImageResolution > 1 / MEGAPIXELS_TO_PIXELS) {
966-
base64Url = await capImageDataURLSize(image.base64Url, maxImageResolution);
967-
}
963+
964+
// Caps the resolution and bakes the jpeg exif orientation in one pass,
965+
// untouched images pass through as is
966+
const base64Url = await capImageDataURLSize(image.base64Url, maxImageResolution);
967+
968968
contentParts.push({
969969
type: ContentPartType.IMAGE_URL,
970970
image_url: { url: base64Url }

tools/ui/src/lib/utils/cap-img-size.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
11
import { MEGAPIXELS_TO_PIXELS } from '$lib/constants/image-size';
22
import { BASE64_IMAGE_URI_REGEX } from '$lib/constants/uri-template';
3+
import { getJpegOrientationFromDataURL, isJpegMimeType } from './jpeg-orientation';
34
import { MimeTypeImage } from '$lib/enums';
45

56
/**
67
* Converts an Image base64 data URL to another Image data URL with capped dimensions to reduce file size.
8+
*
9+
* For JPEGs the EXIF orientation is baked into the pixels in the same canvas
10+
* pass, the browser applies the rotation when decoding so naturalWidth and
11+
* naturalHeight already describe the upright image. Backends decoding with
12+
* stb_image ignore EXIF, see ggml-org/llama.cpp#20870. Images that need
13+
* neither capping nor rotation pass through untouched, so at most one
14+
* re-encode ever happens.
715
* @param base64UrlImage - The Image base64 data URL to convert
8-
* @param maxMegapixels - The maximum image size in megapixels for the output Image
16+
* @param maxMegapixels - The maximum image size in megapixels for the output Image, 0 disables capping
917
* @returns Promise resolving to Image data URL
1018
*/
1119
export function capImageDataURLSize(
@@ -26,6 +34,10 @@ export function capImageDataURLSize(
2634
return reject(new Error(`Unsupported image MIME type: ${mimeType}`));
2735
}
2836

37+
const orientation = isJpegMimeType(mimeType)
38+
? getJpegOrientationFromDataURL(base64UrlImage)
39+
: 1;
40+
2941
const img = new Image();
3042

3143
img.onload = () => {
@@ -46,6 +58,10 @@ export function capImageDataURLSize(
4658
const scaleFactor = Math.sqrt(maxPixels / totalPixels);
4759
canvas.width = Math.floor(targetWidth * scaleFactor);
4860
canvas.height = Math.floor(targetHeight * scaleFactor);
61+
} else if (orientation > 1) {
62+
// No capping needed but the pixels still need the rotation baked in
63+
canvas.width = targetWidth;
64+
canvas.height = targetHeight;
4965
} else {
5066
return resolve(base64UrlImage);
5167
}
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import {
2+
EXIF_SCAN_BYTE_LIMIT,
3+
JPEG_SOI_MARKER,
4+
APP1_MARKER,
5+
SOS_MARKER,
6+
EXIF_SIGNATURE,
7+
TIFF_LITTLE_ENDIAN,
8+
TIFF_MAGIC,
9+
EXIF_ORIENTATION_TAG,
10+
IFD_ENTRY_SIZE
11+
} from '$lib/constants/jpeg-exif';
12+
import { MimeTypeImage } from '$lib/enums';
13+
14+
/**
15+
* Read the EXIF orientation tag from a JPEG base64 data URL
16+
*
17+
* Only a bounded prefix of the base64 payload is decoded, the APP1 segment
18+
* always sits near the start of the file.
19+
* @param base64UrlJpeg - The JPEG base64 data URL to inspect
20+
* @returns The orientation value (1 to 8), or 1 when absent or unreadable
21+
*/
22+
export function getJpegOrientationFromDataURL(base64UrlJpeg: string): number {
23+
try {
24+
const payloadStart = base64UrlJpeg.indexOf(',') + 1;
25+
26+
if (payloadStart <= 0) {
27+
return 1;
28+
}
29+
30+
// Keep the slice a multiple of 4 characters so atob accepts it
31+
const charLimit = Math.ceil(EXIF_SCAN_BYTE_LIMIT / 3) * 4;
32+
const slice = base64UrlJpeg.slice(payloadStart, payloadStart + charLimit);
33+
const binary = atob(slice.slice(0, slice.length - (slice.length % 4)));
34+
const bytes = new Uint8Array(binary.length);
35+
36+
for (let i = 0; i < binary.length; i++) {
37+
bytes[i] = binary.charCodeAt(i);
38+
}
39+
40+
return findExifOrientation(new DataView(bytes.buffer));
41+
} catch {
42+
return 1;
43+
}
44+
}
45+
46+
/**
47+
* Walk the JPEG segments of a header buffer looking for the APP1 EXIF block
48+
* @param view - DataView over the JPEG header bytes
49+
* @returns The orientation value (1 to 8), or 1 when absent or malformed
50+
*/
51+
function findExifOrientation(view: DataView): number {
52+
if (view.byteLength < 4 || view.getUint16(0) !== JPEG_SOI_MARKER) {
53+
return 1;
54+
}
55+
56+
let offset = 2;
57+
58+
while (offset + 4 <= view.byteLength) {
59+
if (view.getUint8(offset) !== 0xff) {
60+
return 1;
61+
}
62+
63+
const marker = view.getUint8(offset + 1);
64+
65+
// Compressed image data starts here: no EXIF past this point
66+
if (marker === SOS_MARKER) {
67+
return 1;
68+
}
69+
70+
const segmentLength = view.getUint16(offset + 2);
71+
72+
if (marker === APP1_MARKER) {
73+
return parseExifOrientation(view, offset + 4, segmentLength);
74+
}
75+
76+
offset += 2 + segmentLength;
77+
}
78+
79+
return 1;
80+
}
81+
82+
/**
83+
* Parse the orientation tag from an APP1 EXIF payload
84+
* @param view - DataView over the JPEG header bytes
85+
* @param start - Offset of the APP1 payload, right after the segment length
86+
* @param segmentLength - Declared APP1 segment length
87+
* @returns The orientation value (1 to 8), or 1 when absent or malformed
88+
*/
89+
function parseExifOrientation(view: DataView, start: number, segmentLength: number): number {
90+
const end = Math.min(start + segmentLength, view.byteLength);
91+
92+
// The payload opens with the "Exif\0\0" signature
93+
if (
94+
start + 6 > end ||
95+
view.getUint32(start) !== EXIF_SIGNATURE ||
96+
view.getUint16(start + 4) !== 0
97+
) {
98+
return 1;
99+
}
100+
101+
const tiff = start + 6;
102+
103+
if (tiff + 8 > end) {
104+
return 1;
105+
}
106+
107+
const littleEndian = view.getUint16(tiff) === TIFF_LITTLE_ENDIAN;
108+
109+
if (view.getUint16(tiff + 2, littleEndian) !== TIFF_MAGIC) {
110+
return 1;
111+
}
112+
113+
const ifdOffset = view.getUint32(tiff + 4, littleEndian);
114+
115+
if (tiff + ifdOffset + 2 > end) {
116+
return 1;
117+
}
118+
119+
const entryCount = view.getUint16(tiff + ifdOffset, littleEndian);
120+
121+
// Scan IFD0 entries for the orientation tag
122+
for (let i = 0; i < entryCount; i++) {
123+
const entry = tiff + ifdOffset + 2 + i * IFD_ENTRY_SIZE;
124+
125+
if (entry + IFD_ENTRY_SIZE > end) {
126+
return 1;
127+
}
128+
129+
if (view.getUint16(entry, littleEndian) === EXIF_ORIENTATION_TAG) {
130+
const orientation = view.getUint16(entry + 8, littleEndian);
131+
132+
return orientation >= 1 && orientation <= 8 ? orientation : 1;
133+
}
134+
}
135+
136+
return 1;
137+
}
138+
139+
/**
140+
* Check if a MIME type represents a JPEG
141+
* @param mimeType - The MIME type to check
142+
* @returns True if the MIME type is a JPEG variant
143+
*/
144+
export function isJpegMimeType(mimeType: string): boolean {
145+
return mimeType === MimeTypeImage.JPEG || mimeType === MimeTypeImage.JPG;
146+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { capImageDataURLSize } from '$lib/utils/cap-img-size';
3+
import { getJpegOrientationFromDataURL } from '$lib/utils/jpeg-orientation';
4+
5+
// Real 64x32 jpegs generated with Pillow, quality 90. The upright picture is
6+
// four solid quadrants: top left red, top right green, bottom left blue,
7+
// bottom right yellow. For each exif value the stored pixels are inverse
8+
// transposed so a conforming decoder shows the upright picture, exactly like
9+
// a rotated smartphone photo.
10+
const EXIF1 = `data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/4QAiRXhpZgAATU0AKgAAAAgAAQESAAMAAAABAAEAAAAAAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wAARCAAgAEADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD50ooor8MP9UwooooA9uooor4I/wCcwKKKKAPhSiiiv+gM/qgKKKKAP3Vooor/AJdz+lQooooA/9k=`;
11+
const EXIF3 = `data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/4QAiRXhpZgAATU0AKgAAAAgAAQESAAMAAAABAAMAAAAAAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wAARCAAgAEADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD7Nooor/Mo/XQooooA/Cqiiiv+og/moKKKKAPuuiiiv+fw/lcKKKKAPEaKKK+9P+jMKKKKAP/Z`;
12+
const EXIF5 = `data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/4QAiRXhpZgAATU0AKgAAAAgAAQESAAMAAAABAAUAAAAAAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wAARCABAACADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD50ooor8MP9UzwKiiiv9xj/HQ99ooor/Dk/wBizwKiiiv9xj/HQ+66KKK/5/D+Vz7qooor+ej/AE9PhWiiiv6FP8wj7qooor+ej/T0/9k=`;
13+
const EXIF6 = `data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/4QAiRXhpZgAATU0AKgAAAAgAAQESAAMAAAABAAYAAAAAAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wAARCABAACADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwDCooor+Tz+Hz7qooor+ej/AE9PhWiiiv6FP8wj7qooor+ej/T0/Keiiiv7CP72PAqKKK/3GP8AHQ99ooor/Dk/2LPAqKKK/wBxj/HQ/9k=`;
14+
const EXIF8 = `data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/4QAiRXhpZgAATU0AKgAAAAgAAQESAAMAAAABAAgAAAAAAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wAARCABAACADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD896KKK/1TPhz32iiiv8OT/Ys8Cooor/cY/wAdD32iiiv8OT/Ys/Viiiiv49P4JPhWiiiv6FP8wj7qooor+ej/AE9PhWiiiv6FP8wj/9k=`;
15+
const NOEXIF = `data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wAARCAAgAEADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD50ooor8MP9UwooooA9uooor4I/wCcwKKKKAPhSiiiv+gM/qgKKKKAP3Vooor/AJdz+lQooooA/9k=`;
16+
17+
const RED: Rgb = [255, 0, 0];
18+
const GREEN: Rgb = [0, 200, 0];
19+
const BLUE: Rgb = [0, 0, 255];
20+
const YELLOW: Rgb = [255, 220, 0];
21+
22+
// Wide tolerance per channel, jpeg compression shifts solid colors a bit
23+
const COLOR_TOLERANCE = 70;
24+
25+
// 0.000512 megapixels is 512 pixels, a quarter of the area of the 2048 pixel fixtures
26+
const QUARTER_AREA_MEGAPIXELS = 0.000512;
27+
28+
type Rgb = [number, number, number];
29+
30+
function loadImage(dataUrl: string): Promise<HTMLImageElement> {
31+
return new Promise((resolve, reject) => {
32+
const img = new Image();
33+
img.onload = () => resolve(img);
34+
img.onerror = () => reject(new Error('Failed to decode image.'));
35+
img.src = dataUrl;
36+
});
37+
}
38+
39+
// Decodes a data URL and samples the center of each quadrant of the picture
40+
async function quadrantColors(dataUrl: string): Promise<Rgb[]> {
41+
const img = await loadImage(dataUrl);
42+
const canvas = document.createElement('canvas');
43+
canvas.width = img.naturalWidth;
44+
canvas.height = img.naturalHeight;
45+
const ctx = canvas.getContext('2d')!;
46+
ctx.drawImage(img, 0, 0);
47+
const points = [
48+
[0.25, 0.25],
49+
[0.75, 0.25],
50+
[0.25, 0.75],
51+
[0.75, 0.75]
52+
];
53+
return points.map(([fx, fy]) => {
54+
const d = ctx.getImageData(
55+
Math.floor(canvas.width * fx),
56+
Math.floor(canvas.height * fy),
57+
1,
58+
1
59+
).data;
60+
return [d[0], d[1], d[2]];
61+
});
62+
}
63+
64+
function expectUpright(colors: Rgb[]) {
65+
const targets = [RED, GREEN, BLUE, YELLOW];
66+
for (let i = 0; i < 4; i++) {
67+
for (let c = 0; c < 3; c++) {
68+
expect(Math.abs(colors[i][c] - targets[i][c])).toBeLessThan(COLOR_TOLERANCE);
69+
}
70+
}
71+
}
72+
73+
describe('capImageDataURLSize orientation and capping', () => {
74+
it('passes upright jpegs through untouched when capping is disabled', async () => {
75+
expect(await capImageDataURLSize(EXIF1, 0)).toBe(EXIF1);
76+
expect(await capImageDataURLSize(NOEXIF, 0)).toBe(NOEXIF);
77+
});
78+
79+
it('passes upright jpegs through untouched when under the cap threshold', async () => {
80+
expect(await capImageDataURLSize(EXIF1, 1)).toBe(EXIF1);
81+
});
82+
83+
it.each([
84+
['orientation 3', EXIF3],
85+
['orientation 5', EXIF5],
86+
['orientation 6', EXIF6],
87+
['orientation 8', EXIF8]
88+
])('bakes %s into upright pixels without capping', async (_label, fixture) => {
89+
const result = await capImageDataURLSize(fixture, 0);
90+
91+
expect(result).not.toBe(fixture);
92+
93+
const img = await loadImage(result);
94+
95+
expect(img.naturalWidth).toBe(64);
96+
expect(img.naturalHeight).toBe(32);
97+
expectUpright(await quadrantColors(result));
98+
99+
// The re-encoded jpeg carries no orientation tag anymore
100+
expect(getJpegOrientationFromDataURL(result)).toBe(1);
101+
});
102+
103+
it('caps and bakes the orientation in a single output', async () => {
104+
const result = await capImageDataURLSize(EXIF6, QUARTER_AREA_MEGAPIXELS);
105+
const img = await loadImage(result);
106+
107+
expect(img.naturalWidth).toBe(32);
108+
expect(img.naturalHeight).toBe(16);
109+
expectUpright(await quadrantColors(result));
110+
expect(getJpegOrientationFromDataURL(result)).toBe(1);
111+
});
112+
113+
it('caps upright jpegs without disturbing the picture', async () => {
114+
const result = await capImageDataURLSize(EXIF1, QUARTER_AREA_MEGAPIXELS);
115+
const img = await loadImage(result);
116+
117+
expect(img.naturalWidth).toBe(32);
118+
expect(img.naturalHeight).toBe(16);
119+
expectUpright(await quadrantColors(result));
120+
});
121+
});

0 commit comments

Comments
 (0)