|
| 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