Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 18 additions & 9 deletions source/image-handler/src/image-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,20 @@ export class ImageHandler {
edits: ImageEdits,
options: SharpOptions,
): Promise<sharp.Sharp> {
let image: sharp.Sharp;
// Bake the EXIF Orientation into the pixels and clear the tag, so output looks upright in
// every engine (Blink ignores WebP EXIF orientation; WebKit honors it). No-op when orientation
// is 1/absent. Must run before resize/crop so those act on oriented dimensions. autoOrient()
// wins over keepMetadata(), so the stale orientation tag is not re-attached.
const image = sharp(originalImage, options).autoOrient().keepIccProfile().keepMetadata();

if (edits?.stripExif) {
// Replace EXIF with a minimal tag; leave the ICC profile untouched.
image.keepIccProfile().withExif({ IFD0: { Software: 'ströer image-handler' } });
}

if (edits && edits.rotate !== undefined && edits.rotate === null) {
image = sharp(originalImage, options);
} else {
const metadata = await sharp(originalImage, options).metadata();
image = metadata.orientation
? sharp(originalImage, options).withMetadata({ orientation: metadata.orientation })
: sharp(originalImage, options).withMetadata();
if (edits?.stripIcc) {
// Normalize the color profile to sRGB (drops the embedded ICC); leave EXIF untouched.
image.keepExif().withIccProfile('srgb');
}

return image;
Expand Down Expand Up @@ -208,7 +213,11 @@ export class ImageHandler {
if (resize.ratio) {
const ratio = resize.ratio;

const { width, height } = resize.width && resize.height ? resize : await originalImage.metadata();
const meta = resize.width && resize.height ? resize : await originalImage.metadata();
// metadata() reports pre-autoOrient dims, so swap for 90/270 rotations (orientation 5-8).
const swap = typeof meta.orientation === 'number' && meta.orientation >= 5;
const width = swap ? meta.height : meta.width;
const height = swap ? meta.width : meta.height;

resize.width = Math.round(width * ratio);
resize.height = Math.round(height * ratio);
Expand Down
7 changes: 5 additions & 2 deletions source/image-handler/src/thumbor-mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,9 +356,12 @@ export class ThumborMapper {
this.mapStretch(currentEdits);
break;
}
case 'strip_exif':
case 'strip_exif': {
currentEdits.stripExif = true;
break;
}
case 'strip_icc': {
currentEdits.rotate = null;
currentEdits.stripIcc = true;
break;
}
case 'upscale': {
Expand Down
67 changes: 58 additions & 9 deletions source/image-handler/test/image-handler/rotate.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,29 +10,49 @@ import { ImageHandler } from '../../src/image-handler';
const s3Client = new S3();

describe('rotate', () => {
it('Should pass if rotate is null and return image without EXIF and ICC', async () => {
it('Should keep the ICC profile when stripExif is set (and normalize orientation)', async () => {
// Arrange
const originalImage = fs.readFileSync('./test/image/1x1.jpg'); // has EXIF + ICC + orientation 3
const request: ImageRequestInfo = {
requestType: RequestTypes.DEFAULT,
bucket: 'sample-bucket',
key: 'test.jpg',
edits: { stripExif: true },
originalImage: originalImage,
};

// Act
const imageHandler = new ImageHandler(s3Client);
const result = await imageHandler.process(request);

// Assert: EXIF stripping keeps ICC untouched; orientation baked in and cleared
const metadata = await sharp(Buffer.from(result, 'base64')).metadata();
expect(metadata).toHaveProperty('icc');
expect(metadata.orientation ?? 1).toBe(1);
});

it('Should keep EXIF when stripIcc is set (and normalize orientation)', async () => {
// Arrange
const originalImage = fs.readFileSync('./test/image/1x1.jpg');
const request: ImageRequestInfo = {
requestType: RequestTypes.DEFAULT,
bucket: 'sample-bucket',
key: 'test.jpg',
edits: { rotate: null },
edits: { stripIcc: true },
originalImage: originalImage,
};

// Act
const imageHandler = new ImageHandler(s3Client);
const result = await imageHandler.process(request);

// Assert
// Assert: ICC stripping keeps EXIF untouched; orientation baked in and cleared
const metadata = await sharp(Buffer.from(result, 'base64')).metadata();
expect(metadata).not.toHaveProperty('exif');
expect(metadata).not.toHaveProperty('icc');
expect(metadata).not.toHaveProperty('orientation');
expect(metadata).toHaveProperty('exif');
expect(metadata.orientation ?? 1).toBe(1);
});

it('Should pass if the original image has orientation', async () => {
it('Should bake EXIF orientation into pixels and normalize the tag', async () => {
// Arrange
const originalImage = fs.readFileSync('./test/image/1x1.jpg');
const request: ImageRequestInfo = {
Expand All @@ -47,9 +67,38 @@ describe('rotate', () => {
const imageHandler = new ImageHandler(s3Client);
const result = await imageHandler.process(request);

// Assert
// Assert: orientation is auto-applied and cleared (was 3), not re-injected as a tag
const metadata = await sharp(Buffer.from(result, 'base64')).metadata();
expect(metadata.orientation ?? 1).toBe(1);
});

it('Should physically rotate pixels so output dims match the oriented source (all engines)', async () => {
// Arrange: 1366x768 landscape pixels + EXIF Orientation=6 (rotate 90 CW) = a portrait photo
const landscape = await sharp({
create: { width: 1366, height: 768, channels: 3, background: { r: 200, g: 100, b: 50 } },
})
.jpeg()
.toBuffer();
const originalImage = await sharp(landscape).withMetadata({ orientation: 6 }).jpeg().toBuffer();

const request: ImageRequestInfo = {
requestType: RequestTypes.DEFAULT,
bucket: 'sample-bucket',
key: 'test.jpg',
contentType: 'image/webp',
edits: {},
originalImage,
};

// Act
const imageHandler = new ImageHandler(s3Client);
const result = await imageHandler.process(request);

// Assert: pixels are physically rotated to portrait, and no stale orientation tag remains
const metadata = await sharp(Buffer.from(result, 'base64')).metadata();
expect(metadata.orientation).toEqual(3);
expect(metadata.width).toBe(768);
expect(metadata.height).toBe(1366);
expect(metadata.orientation ?? 1).toBe(1);
});

it('Should pass if the original image does not have orientation', async () => {
Expand Down
41 changes: 20 additions & 21 deletions source/image-handler/test/image-handler/standard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import { ImageHandler } from '../../src/image-handler';

const s3Client = new S3();
const image = fs.readFileSync('./test/image/25x15.png');
const withMetatdataSpy = jest.spyOn(sharp.prototype, 'withMetadata');
const autoOrientSpy = jest.spyOn(sharp.prototype, 'autoOrient');
const keepMetadataSpy = jest.spyOn(sharp.prototype, 'keepMetadata');
const withExifSpy = jest.spyOn(sharp.prototype, 'withExif');
const withIccProfileSpy = jest.spyOn(sharp.prototype, 'withIccProfile');

describe('standard', () => {
it('Should pass if a series of standard edits are provided to the function', async () => {
Expand Down Expand Up @@ -39,50 +42,46 @@ describe('instantiateSharpImage', () => {
jest.clearAllMocks();
});

it('Should not include metadata if the rotation is null', async () => {
it('Should auto-orient and keep metadata by default, without stripping EXIF or ICC', async () => {
// Arrange
const edits = {
rotate: null,
};
const options: SharpOptions = { failOn: 'none' };
const imageHandler = new ImageHandler(s3Client);

// Act
await imageHandler['instantiateSharpImage'](image, edits, options);
await imageHandler['instantiateSharpImage'](image, {}, options);

//Assert
expect(withMetatdataSpy).not.toHaveBeenCalled();
expect(autoOrientSpy).toHaveBeenCalled();
expect(keepMetadataSpy).toHaveBeenCalled();
expect(withExifSpy).not.toHaveBeenCalled();
expect(withIccProfileSpy).not.toHaveBeenCalled();
});

it('Should include metadata and not define orientation if the rotation is not null and orientation is not defined', async () => {
it('Should strip EXIF but keep the ICC profile when stripExif is set', async () => {
// Arrange
const edits = {
rotate: undefined,
};
const options: SharpOptions = { failOn: 'none' };
const imageHandler = new ImageHandler(s3Client);

// Act
await imageHandler['instantiateSharpImage'](image, edits, options);
await imageHandler['instantiateSharpImage'](image, { stripExif: true }, options);

//Assert
expect(withMetatdataSpy).toHaveBeenCalled();
expect(withMetatdataSpy).not.toHaveBeenCalledWith(expect.objectContaining({ orientation: expect.anything }));
expect(autoOrientSpy).toHaveBeenCalled();
expect(withExifSpy).toHaveBeenCalled();
expect(withIccProfileSpy).not.toHaveBeenCalled();
});

it('Should include orientation metadata if the rotation is defined in the metadata', async () => {
it('Should strip the ICC profile but keep EXIF when stripIcc is set', async () => {
// Arrange
const edits = {
rotate: undefined,
};
const options: SharpOptions = { failOn: 'none' };
const modifiedImage = await sharp(image).withMetadata({ orientation: 1 }).toBuffer();
const imageHandler = new ImageHandler(s3Client);

// Act
await imageHandler['instantiateSharpImage'](modifiedImage, edits, options);
await imageHandler['instantiateSharpImage'](image, { stripIcc: true }, options);

//Assert
expect(withMetatdataSpy).toHaveBeenCalledWith({ orientation: 1 });
expect(autoOrientSpy).toHaveBeenCalled();
expect(withIccProfileSpy).toHaveBeenCalledWith('srgb');
expect(withExifSpy).not.toHaveBeenCalled();
});
});
2 changes: 1 addition & 1 deletion source/image-handler/test/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ describe('index', () => {
Vary: 'Accept',
'Last-Modified': undefined,
},
body: '/9j/4QC8RXhpZgAASUkqAAgAAAAGABIBAwABAAAAAQAAABoBBQABAAAAVgAAABsBBQABAAAAXgAAACgBAwABAAAAAgAAABMCAwABAAAAAQAAAGmHBAABAAAAZgAAAAAAAABIAAAAAQAAAEgAAAABAAAABgAAkAcABAAAADAyMTABkQcABAAAAAECAwAAoAcABAAAADAxMDABoAMAAQAAAP//AAACoAQAAQAAAAEAAAADoAQAAQAAAAEAAAAAAAAA/+IB8ElDQ19QUk9GSUxFAAEBAAAB4GxjbXMEIAAAbW50clJHQiBYWVogB+IAAwAUAAkADgAdYWNzcE1TRlQAAAAAc2F3c2N0cmwAAAAAAAAAAAAAAAAAAPbWAAEAAAAA0y1oYW5keem/Vlo+AbaDI4VVRvdPqgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKZGVzYwAAAPwAAAAkY3BydAAAASAAAAAid3RwdAAAAUQAAAAUY2hhZAAAAVgAAAAsclhZWgAAAYQAAAAUZ1hZWgAAAZgAAAAUYlhZWgAAAawAAAAUclRSQwAAAcAAAAAgZ1RSQwAAAcAAAAAgYlRSQwAAAcAAAAAgbWx1YwAAAAAAAAABAAAADGVuVVMAAAAIAAAAHABzAFIARwBCbWx1YwAAAAAAAAABAAAADGVuVVMAAAAGAAAAHABDAEMAMAAAWFlaIAAAAAAAAPbWAAEAAAAA0y1zZjMyAAAAAAABDD8AAAXd///zJgAAB5AAAP2S///7of///aIAAAPcAADAcVhZWiAAAAAAAABvoAAAOPIAAAOPWFlaIAAAAAAAAGKWAAC3iQAAGNpYWVogAAAAAAAAJKAAAA+FAAC2xHBhcmEAAAAAAAMAAAACZmkAAPKnAAANWQAAE9AAAApb/9sAQwAGBgYGBwYHCAgHCgsKCwoPDgwMDg8WEBEQERAWIhUZFRUZFSIeJB4cHiQeNiomJio2PjQyND5MRERMX1pffHyn/9sAQwEGBgYGBwYHCAgHCgsKCwoPDgwMDg8WEBEQERAWIhUZFRUZFSIeJB4cHiQeNiomJio2PjQyND5MRERMX1pffHyn/8IAEQgAAQABAwEiAAIRAQMRAf/EABUAAQEAAAAAAAAAAAAAAAAAAAAH/8QAFQEBAQAAAAAAAAAAAAAAAAAABQf/2gAMAwEAAhADEAAAAIOA6p//xAAUEAEAAAAAAAAAAAAAAAAAAAAA/9oACAEBAAE/AH//xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oACAECAQE/AH//xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oACAEDAQE/AH//2Q==',
body: '/9j/4QC8RXhpZgAASUkqAAgAAAAGABIBAwABAAAAAQAAABoBBQABAAAAVgAAABsBBQABAAAAXgAAACgBAwABAAAAAgAAABMCAwABAAAAAQAAAGmHBAABAAAAZgAAAAAAAABIAAAAAQAAAEgAAAABAAAABgAAkAcABAAAADAyMTABkQcABAAAAAECAwAAoAcABAAAADAxMDABoAMAAQAAAP//AAACoAQAAQAAAAEAAAADoAQAAQAAAAEAAAAAAAAA/9sAQwAGBgYGBwYHCAgHCgsKCwoPDgwMDg8WEBEQERAWIhUZFRUZFSIeJB4cHiQeNiomJio2PjQyND5MRERMX1pffHyn/9sAQwEGBgYGBwYHCAgHCgsKCwoPDgwMDg8WEBEQERAWIhUZFRUZFSIeJB4cHiQeNiomJio2PjQyND5MRERMX1pffHyn/8IAEQgAAQABAwEiAAIRAQMRAf/EABUAAQEAAAAAAAAAAAAAAAAAAAAH/8QAFQEBAQAAAAAAAAAAAAAAAAAABQf/2gAMAwEAAhADEAAAAIOA6p//xAAUEAEAAAAAAAAAAAAAAAAAAAAA/9oACAEBAAE/AH//xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oACAECAQE/AH//xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oACAEDAQE/AH//2Q==',
};

// Assert
Expand Down
4 changes: 2 additions & 2 deletions source/image-handler/test/thumbor-mapper/filter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,7 @@ describe('filter', () => {
const edits = thumborMapper.mapFilter(edit, filetype);

// Assert
const expectedResult = { rotate: null };
const expectedResult = { stripExif: true };
expect(edits).toEqual(expectedResult);
});

Expand All @@ -444,7 +444,7 @@ describe('filter', () => {
const edits = thumborMapper.mapFilter(edit, filetype);

// Assert
const expectedResult = { rotate: null };
const expectedResult = { stripIcc: true };
expect(edits).toEqual(expectedResult);
});

Expand Down