import { promises as fs } from 'fs'; import os from 'os'; import path from 'path'; import sharp from 'sharp'; import { rotateInPlace } from '../../src/imageProcessing'; // libvips keeps an input file mapped in its operation cache after a pipeline // finishes, and Windows will not delete a file that still has an open handle — // so the animated-WebP case, which is the one that ends in a rejection, left // afterEach unable to remove its own temporary directory. Disabling the cache // costs these six tests nothing: every one of them reads its file exactly once. sharp.cache(false); let dir = ''; beforeEach(async () => { dir = await fs.mkdtemp(path.join(os.tmpdir(), 'rotate-')); }); afterEach(async () => { await fs.rm(dir, { recursive: true, force: true }); }); /** * A square image, left half red and right half blue. * * Square on purpose. Dimensions swap whichever way a quarter turn goes, so a * rectangle proves that something rotated but says nothing about which way — * only the pixels can say that, and this is the smallest picture that makes * the direction visible. */ async function halvesPng(): Promise { const red = await sharp({ create: { width: 100, height: 200, channels: 3, background: { r: 255, g: 0, b: 0 } } }) .png() .toBuffer(); const blue = await sharp({ create: { width: 100, height: 200, channels: 3, background: { r: 0, g: 0, b: 255 } } }) .png() .toBuffer(); return sharp({ create: { width: 200, height: 200, channels: 3, background: { r: 0, g: 0, b: 0 } } }) .composite([ { input: red, left: 0, top: 0 }, { input: blue, left: 100, top: 0 } ]) .png() .toBuffer(); } /** The colour a little way in from the top-left corner, clear of any edge. */ async function topLeft(file: string): Promise { const { data, info } = await sharp(file).raw().toBuffer({ resolveWithObject: true }); const at = (10 * info.width + 10) * info.channels; return [data[at], data[at + 1], data[at + 2]].join(','); } /** * A two-frame animated WebP, built rather than committed as a binary fixture so * what it contains is readable here. `pageHeight` is what makes libvips treat * one tall buffer as a strip of frames. */ async function animatedWebp(): Promise { const width = 40; const height = 40; const raw = Buffer.alloc(width * height * 2 * 4); for (let i = 0; i < width * height * 4; i += 4) { raw[i] = 255; raw[i + 3] = 255; } for (let i = width * height * 4; i < raw.length; i += 4) { raw[i + 2] = 255; raw[i + 3] = 255; } return sharp(raw, { raw: { width, height: height * 2, channels: 4, pageHeight: height } }) .webp() .toBuffer(); } describe('rotateInPlace', () => { it('turns a landscape photo into a portrait one', async () => { const file = path.join(dir, 'wide.jpg'); await fs.writeFile( file, await sharp({ create: { width: 400, height: 200, channels: 3, background: { r: 20, g: 60, b: 120 } } }) .jpeg() .toBuffer() ); await rotateInPlace(file, 'image/jpeg', 'right'); const after = await sharp(file).metadata(); expect(after.width).toBe(200); expect(after.height).toBe(400); }); // The direction mapping, which is the one thing here that can be exactly // backwards while every dimension assertion still passes. Turning clockwise // moves the left edge to the top, so the red half ends up along the top. it('turns right clockwise', async () => { const file = path.join(dir, 'halves.png'); await fs.writeFile(file, await halvesPng()); await rotateInPlace(file, 'image/png', 'right'); expect(await topLeft(file)).toBe('255,0,0'); }); it('turns left anticlockwise', async () => { const file = path.join(dir, 'halves.png'); await fs.writeFile(file, await halvesPng()); await rotateInPlace(file, 'image/png', 'left'); expect(await topLeft(file)).toBe('0,0,255'); }); // Format is preserved for the reason #226 preserved it: image_path carries // the extension, so changing the format here would leave the row pointing at // a file that no longer exists. it('keeps the file in its own format', async () => { const file = path.join(dir, 'halves.png'); await fs.writeFile(file, await halvesPng()); await rotateInPlace(file, 'image/png', 'left'); expect((await sharp(file).metadata()).format).toBe('png'); }); // The whole reason for the temp-file-and-rename: a failure must leave the // photo exactly as it was, and must not leave a stray file behind either. it('leaves the file untouched when sharp cannot read it', async () => { const file = path.join(dir, 'broken.jpg'); await fs.writeFile(file, 'not an image'); await expect(rotateInPlace(file, 'image/jpeg', 'right')).rejects.toThrow(); expect(await fs.readFile(file, 'utf8')).toBe('not an image'); expect(await fs.readdir(dir)).toEqual(['broken.jpg']); }); // The trap this guards. Reading an animated WebP without `animated: true` // succeeds and yields the first frame alone, so a rotation that omitted the // flag would write back a still and destroy the uploader's animation while // reporting success. With the flag, sharp refuses — which is the correct // answer, because a quarter turn of a multi-page image is not something it // can do. it('refuses an animated WebP rather than flattening it', async () => { const file = path.join(dir, 'moving.webp'); await fs.writeFile(file, await animatedWebp()); await expect(rotateInPlace(file, 'image/webp', 'right')).rejects.toThrow(/multi-page/i); expect((await sharp(file, { animated: true }).metadata()).pages).toBe(2); }); });