Add Image Optimization rule to the dashbard

This commit is contained in:
Gaël Métais
2015-06-01 14:57:05 +02:00
parent c428c8ae2d
commit 1606cbab18
10 changed files with 351 additions and 133 deletions
+10
View File
@@ -862,6 +862,16 @@ var policies = {
"hasOffenders": true,
"unit": 'bytes'
},
"imageOptimization": {
"tool": "weightChecker",
"label": "Image optimization",
"message": "<p>This metric mesures the number of bytes that could be saved by optimizing images.</p><p>Image optimization is generally one of the easiest way to reduce a page weight, and as a result, the page load time. Don't use Photoshop or other image editing tools, they lie to you because they're generally not very good for optimization. Use specialized tools such as <a href=\"https://kraken.io/\" target=\"_blank\">Kraken.io</a> or the excellent <a href=\"https://imageoptim.com/\" target=\"_blank\">ImageOption</a> on Mac.</p><p>The tools in use in YellowLabTools are not set to their maximum optimization power, so you might be able to compress even more (the max JPEG quality is set to 85, which should be sufficient for any website).</p>",
"isOkThreshold": 30720,
"isBadThreshold": 122880,
"isAbnormalThreshold": 204800,
"hasOffenders": true,
"unit": 'bytes'
},
"requests": {
"tool": "phantomas",
"label": "Total requests number",
+2 -1
View File
@@ -72,7 +72,8 @@
"pageWeight": {
"label": "Page weight",
"policies": {
"totalWeight": 5
"totalWeight": 5,
"imageOptimization": 3
}
},
"requests": {
+116 -95
View File
@@ -7,25 +7,38 @@ var jpegoptim = require('imagemin-jpegoptim');
var ImageOptimizer = function() {
var MAX_JPEG_QUALITY = 85;
var OPTIPNG_COMPRESSION_LEVEL = 2;
function optimizeImage(entry) {
var deferred = Q.defer();
var fileSize = entry.weightCheck.body.length;
if (!entry.weightCheck.body) {
// No valid file available
deferred.resolve(entry);
return deferred.promise;
}
var fileSize = entry.weightCheck.bodySize;
debug('Let\'s try to optimize %s', entry.url);
debug('Current file size is %d', fileSize);
if (isJpeg(entry)) {
debug('File is a JPEG');
// Starting softly with a lossless compression
return compressJpegLosslessly(entry.weightCheck.body)
return compressJpegLosslessly(new Buffer(entry.weightCheck.body, 'binary'))
.then(function(newFile) {
if (!newFile) {
debug('Optimization didn\'t work');
return entry;
}
var newFileSize = newFile.contents.length;
debug('JPEG lossless compression complete for %s', entry.url);
if (newFileSize < fileSize) {
if (gainIsEnough(fileSize, newFileSize)) {
entry.weightCheck.lossless = entry.weightCheck.optimized = newFileSize;
entry.weightCheck.isOptimized = false;
debug('Filesize is %d bytes smaller (-%d%)', fileSize - newFileSize, Math.round((fileSize - newFileSize) * 100 / fileSize));
@@ -33,24 +46,34 @@ var ImageOptimizer = function() {
// Now let's compress lossy to MAX_JPEG_QUALITY
return compressJpegLossly(entry.weightCheck.body);
return compressJpegLossly(new Buffer(entry.weightCheck.body, 'binary'));
})
.then(function(newFile) {
if (!newFile) {
debug('Optimization didn\'t work');
return entry;
}
var newFileSize = newFile.contents.length;
debug('JPEG lossy compression complete for %s', entry.url);
if (newFileSize < fileSize) {
if (gainIsEnough(fileSize, newFileSize)) {
if (entry.weightCheck.isOptimized !== false || newFileSize < entry.weightCheck.lossless) {
entry.weightCheck.optimized = newFileSize;
}
entry.weightCheck.lossy = newFileSize;
entry.weightCheck.isOptimized = false;
debug('Filesize is %d bytes smaller (-%d%)', fileSize - newFileSize, Math.round((fileSize - newFileSize) * 100 / fileSize));
if (newFileSize < entry.weightCheck.lossless) {
entry.weightCheck.optimized = newFileSize;
}
}
return entry;
})
.fail(function() {
return entry;
});
@@ -59,19 +82,32 @@ var ImageOptimizer = function() {
debug('File is a PNG');
// Starting softly with a lossless compression
return compressPngLosslessly(entry.weightCheck.body)
return compressPngLosslessly(new Buffer(entry.weightCheck.body, 'binary'))
.then(function(newFile) {
if (!newFile) {
debug('Optimization didn\'t work');
return entry;
}
var newFileSize = newFile.contents.length;
debug('PNG lossless compression complete for %s', entry.url);
if (newFileSize < fileSize) {
debug('Old file size: %d', fileSize);
debug('New file size: %d', newFileSize);
debug('newgainIsEnough: %s', gainIsEnough(fileSize, newFileSize) ? 'true':'false');
if (gainIsEnough(fileSize, newFileSize)) {
entry.weightCheck.lossless = entry.weightCheck.optimized = newFileSize;
entry.weightCheck.isOptimized = false;
debug('Filesize is %d bytes smaller (-%d%)', fileSize - newFileSize, Math.round((fileSize - newFileSize) * 100 / fileSize));
}
return entry;
})
.fail(function() {
return entry;
});
@@ -80,137 +116,122 @@ var ImageOptimizer = function() {
debug('File is an SVG');
// Starting softly with a lossless compression
return compressSvgLosslessly(entry.weightCheck.body)
return compressSvgLosslessly(new Buffer(entry.weightCheck.body, 'utf8'))
.then(function(newFile) {
if (!newFile) {
debug('Optimization didn\'t work');
return entry;
}
var newFileSize = newFile.contents.length;
debug('SVG lossless compression complete for %s', entry.url);
if (newFileSize < fileSize) {
if (gainIsEnough(fileSize, newFileSize)) {
entry.weightCheck.lossless = entry.weightCheck.optimized = newFileSize;
entry.weightCheck.isOptimized = false;
debug('Filesize is %d bytes smaller (-%d%)', fileSize - newFileSize, Math.round((fileSize - newFileSize) * 100 / fileSize));
}
return entry;
})
.fail(function() {
return entry;
});
} else {
debug('File type is not an optimizable image');
debug('File type %s is not an (optimizable) image', entry.contentType);
deferred.resolve(entry);
}
return deferred.promise;
}
function gainIsEnough(oldWeight, newWeight) {
var gain = oldWeight - newWeight;
var ratio = gain / oldWeight;
return (gain > 2048 || ratio > 0.2);
}
function isJpeg(entry) {
return entry.isImage && entry.contentType === 'image/jpeg';
}
function compressJpegLosslessly(imageBody) {
var deferred = Q.defer();
var startTime = Date.now();
debug('Starting JPEG lossless compression');
new Imagemin()
.src(imageBody)
.use(Imagemin.jpegtran())
.run(function (err, files) {
if (err) {
deferred.reject(err);
} else {
deferred.resolve(files[0]);
var endTime = Date.now();
debug('compressJpegLosslessly took %d ms', endTime - startTime);
}
});
return deferred.promise;
}
function compressJpegLossly(imageBody) {
var deferred = Q.defer();
var startTime = Date.now();
debug('Starting JPEG lossy compression');
new Imagemin()
.src(imageBody)
.use(jpegoptim({max: MAX_JPEG_QUALITY}))
.run(function (err, files) {
if (err) {
deferred.reject(err);
} else {
deferred.resolve(files[0]);
var endTime = Date.now();
debug('compressJpegLossly took %d ms', endTime - startTime);
}
});
return deferred.promise;
}
function isPNG(entry) {
return entry.isImage && entry.contentType === 'image/png';
}
function compressPngLosslessly(imageBody) {
var deferred = Q.defer();
var startTime = Date.now();
debug('Starting PNG losslessly compression');
new Imagemin()
.src(imageBody)
.use(Imagemin.optipng({optimizationLevel: 2}))
.run(function (err, files) {
if (err) {
deferred.reject(err);
} else {
deferred.resolve(files[0]);
var endTime = Date.now();
debug('compressPngLosslessly took %d ms', endTime - startTime);
}
});
return deferred.promise;
}
function isSVG(entry) {
return entry.isImage && entry.contentType === 'image/svg+xml';
}
function compressJpegLosslessly(imageBody) {
return imageminLauncher(imageBody, 'jpeg', false);
}
function compressJpegLossly(imageBody) {
return imageminLauncher(imageBody, 'jpeg', true);
}
function compressPngLosslessly(imageBody) {
return imageminLauncher(imageBody, 'png', false);
}
function compressSvgLosslessly(imageBody) {
return imageminLauncher(imageBody, 'svg', false);
}
function imageminLauncher(imageBody, type, lossy) {
var deferred = Q.defer();
var startTime = Date.now();
debug('Starting SVG losslessly compression');
debug('Starting %s %s optimization', type, lossy ? 'lossy' : 'lossless');
new Imagemin()
.src(imageBody)
.use(Imagemin.svgo())
.run(function (err, files) {
if (err) {
deferred.reject(err);
} else {
deferred.resolve(files[0]);
var endTime = Date.now();
debug('compressSvgLosslessly took %d ms', endTime - startTime);
}
});
var engine;
if (type === 'jpeg' && !lossy) {
engine = Imagemin.jpegtran();
} else if (type === 'jpeg' && lossy) {
engine = jpegoptim({max: MAX_JPEG_QUALITY});
} else if (type === 'png' && !lossy) {
engine = Imagemin.optipng({optimizationLevel: OPTIPNG_COMPRESSION_LEVEL});
} else if (type === 'svg' && !lossy) {
engine = Imagemin.svgo();
} else {
deferred.reject('No optimization engine found for imagemin');
}
try {
new Imagemin()
.src(imageBody)
.use(engine)
.run(function (err, files) {
if (err) {
deferred.reject(err);
} else {
deferred.resolve(files[0]);
var endTime = Date.now();
debug('Optimization for %s took %d ms', type, endTime - startTime);
}
});
} catch(err) {
deferred.reject(err);
}
return deferred.promise;
}
return {
//recompressIfImage: recompressIfImage,
recompressIfImage: optimizeImage,
optimizeImage: optimizeImage,
compressJpegLosslessly: compressJpegLosslessly,
compressJpegLossly: compressJpegLossly,
compressPngLosslessly: compressPngLosslessly,
compressSvgLosslessly: compressSvgLosslessly
compressSvgLosslessly: compressSvgLosslessly,
gainIsEnough: gainIsEnough
};
};
+90 -23
View File
@@ -5,18 +5,23 @@
*/
var debug = require('debug')('ylt:weightChecker');
var Q = require('q');
var http = require('http');
var zlib = require('zlib');
var async = require('async');
var request = require('request');
var debug = require('debug')('ylt:weightChecker');
var Q = require('q');
var http = require('http');
var zlib = require('zlib');
var async = require('async');
var request = require('request');
var imageOptimizer = require('./imageOptimizer');
var WeightChecker = function() {
var MAX_PARALLEL_DOWNLOADS = 10;
var REQUEST_TIMEOUT = 10000; // 10 seconds
// This function will re-download every asset and check if it could be optimized
function recheckAllFiles(data) {
var deferred = Q.defer();
@@ -30,10 +35,10 @@ var WeightChecker = function() {
redownloadEntry(entry)
.then(recompressIfImage)
.then(imageOptimizer.recompressIfImage)
.then(function(entry) {
callback(null, entry);
.then(function(newEntry) {
callback(null, newEntry);
})
.fail(function(err) {
@@ -58,6 +63,10 @@ var WeightChecker = function() {
offenders.totalWeight = listRequestWeight(results);
metrics.totalWeight = offenders.totalWeight.totalWeight;
// Image compression
offenders.imageOptimization = listImageNotOptimized(results);
metrics.imageOptimization = offenders.imageOptimization.totalGain;
data.toolsResults.weightChecker = {
metrics: metrics,
@@ -128,6 +137,62 @@ var WeightChecker = function() {
}
function listImageNotOptimized(requests) {
var results = {
totalGain: 0,
byType: {
jpeg: {
totalGain: 0,
requests: []
},
png: {
totalGain: 0,
requests: []
},
svg: {
totalGain: 0,
requests: []
}
}
};
requests.forEach(function(req) {
var type = null;
switch(req.contentType) {
case 'image/jpeg':
type = 'jpeg';
break;
case 'image/png':
type = 'png';
break;
case 'image/svg+xml':
type = 'svg';
break;
}
if (type && req.weightCheck.bodySize && req.weightCheck.isOptimized === false) {
var gain = req.weightCheck.bodySize - req.weightCheck.optimized;
results.totalGain += gain;
results.byType[type].totalGain += gain;
results.byType[type].requests.push({
url: req.url,
original: req.weightCheck.bodySize,
optimized: req.weightCheck.optimized,
lossless: req.weightCheck.lossless,
lossy: req.weightCheck.lossy,
gain: gain
});
}
});
return results;
}
function redownloadEntry(entry) {
var deferred = Q.defer();
@@ -136,7 +201,7 @@ var WeightChecker = function() {
entry.weightCheck = {
message: message
};
deferred.reject();
deferred.resolve(entry);
}
if (entry.method !== 'GET') {
@@ -161,9 +226,10 @@ var WeightChecker = function() {
url: entry.url,
headers: reqHeaders,
timeout: REQUEST_TIMEOUT
//encoding: (entry.contentType === 'image/jpeg' || entry.contentType === 'image/png') ? 'binary' : null
};
download(requestOptions, function(error, result) {
download(requestOptions, entry.contentType, function(error, result) {
if (error) {
if (error.code === 'ETIMEDOUT') {
onError('timeout after ' + REQUEST_TIMEOUT + 'ms');
@@ -183,7 +249,7 @@ var WeightChecker = function() {
}
// Inspired by https://github.com/cvan/fastHAR-api/blob/10cec585/app.js
function download(requestOptions, callback) {
function download(requestOptions, contentType, callback) {
var statusCode;
@@ -226,6 +292,7 @@ var WeightChecker = function() {
switch (res.headers['content-encoding']) {
case 'gzip':
var gzip = zlib.createGunzip();
gzip.on('data', function (data) {
@@ -234,6 +301,8 @@ var WeightChecker = function() {
}).on('end', function () {
isCompressed = true;
tally();
}).on('error', function(err) {
debug(err);
});
res.on('data', function (data) {
@@ -242,6 +311,8 @@ var WeightChecker = function() {
break;
case 'deflate':
res.setEncoding('utf8');
var deflate = zlib.createInflate();
deflate.on('data', function (data) {
@@ -250,6 +321,8 @@ var WeightChecker = function() {
}).on('end', function () {
isCompressed = true;
tally();
}).on('error', function(err) {
debug(err);
});
res.on('data', function (data) {
@@ -258,6 +331,10 @@ var WeightChecker = function() {
break;
default:
if (contentType === 'image/jpeg' || contentType === 'image/png') {
res.setEncoding('binary');
}
res.on('data', function (data) {
body += data;
uncompressedSize += data.length;
@@ -280,21 +357,11 @@ var WeightChecker = function() {
}
function recompressIfImage(entry) {
var deferred = Q.defer();
deferred.resolve(entry);
return deferred.promise;
}
return {
recheckAllFiles: recheckAllFiles,
listRequestWeight: listRequestWeight,
redownloadEntry: redownloadEntry,
download: download,
recompressIfImage: recompressIfImage
download: download
};
};
+48 -10
View File
@@ -6,7 +6,7 @@ var path = require('path');
describe('imageOptimizer', function() {
it('should optimize a JPEG image losslessly', function(done) {
var fileContent = fs.readFileSync(path.resolve(__dirname, '../fixtures/jpeg-image.jpg'));
var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/jpeg-image.jpg'));
var fileSize = fileContent.length;
@@ -20,7 +20,7 @@ describe('imageOptimizer', function() {
});
it('should optimize a JPEG image lossly', function(done) {
var fileContent = fs.readFileSync(path.resolve(__dirname, '../fixtures/jpeg-image.jpg'));
var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/jpeg-image.jpg'));
var fileSize = fileContent.length;
@@ -34,7 +34,7 @@ describe('imageOptimizer', function() {
});
it('should find the best optimization for a jpeg', function(done) {
var fileContent = fs.readFileSync(path.resolve(__dirname, '../fixtures/jpeg-image.jpg'));
var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/jpeg-image.jpg'));
var fileSize = fileContent.length;
var entry = {
@@ -77,7 +77,7 @@ describe('imageOptimizer', function() {
});
it('should optimize a PNG image losslessly', function(done) {
var fileContent = fs.readFileSync(path.resolve(__dirname, '../fixtures/png-image.png'));
var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/png-image.png'));
var fileSize = fileContent.length;
@@ -90,8 +90,36 @@ describe('imageOptimizer', function() {
});
});
it('should fail to optimize an already optimized PNG', function(done) {
var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/logo-large.png'));
var fileSize = fileContent.length;
imageOptimizer.compressPngLosslessly(fileContent).then(function(newFile) {
var newFileSize = newFile.contents.length;
newFileSize.should.equal(fileSize);
done();
}).fail(function(err) {
done(err);
});
});
it('should fail to optimize a non-PNG', function(done) {
var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/svg-image.svg'));
var fileSize = fileContent.length;
imageOptimizer.compressPngLosslessly(fileContent).then(function(newFile) {
var newFileSize = newFile.contents.length;
newFileSize.should.equal(fileSize);
done();
}).fail(function(err) {
done(err);
});
});
it('should optimize a png', function(done) {
var fileContent = fs.readFileSync(path.resolve(__dirname, '../fixtures/png-image.png'));
var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/png-image.png'));
var fileSize = fileContent.length;
var entry = {
@@ -132,9 +160,8 @@ describe('imageOptimizer', function() {
});
});
it('should optimize an SVG image losslessly', function(done) {
var fileContent = fs.readFileSync(path.resolve(__dirname, '../fixtures/svg-image.svg'));
var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/svg-image.svg'));
var fileSize = fileContent.length;
@@ -148,7 +175,7 @@ describe('imageOptimizer', function() {
});
it('should optimize an SVG', function(done) {
var fileContent = fs.readFileSync(path.resolve(__dirname, '../fixtures/svg-image.svg'));
var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/svg-image.svg'));
var fileSize = fileContent.length;
var entry = {
@@ -194,7 +221,7 @@ describe('imageOptimizer', function() {
// In this test, we try to optimize a PNG but with a falsy "image/jpeg" content type
var fileContent = fs.readFileSync(path.resolve(__dirname, '../fixtures/png-image.png'));
var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/png-image.png'));
var fileSize = fileContent.length;
var entry = {
@@ -237,7 +264,7 @@ describe('imageOptimizer', function() {
// In this test, we try to optimize a JPEG but with a falsy "image/png" content type
var fileContent = fs.readFileSync(path.resolve(__dirname, '../fixtures/jpeg-image.jpg'));
var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/jpeg-image.jpg'));
var fileSize = fileContent.length;
var entry = {
@@ -276,4 +303,15 @@ describe('imageOptimizer', function() {
});
});
it('should determine if gain is enough', function() {
imageOptimizer.gainIsEnough(20000, 10000).should.equal(true);
imageOptimizer.gainIsEnough(2000, 1000).should.equal(true);
imageOptimizer.gainIsEnough(20000, 21000).should.equal(false);
imageOptimizer.gainIsEnough(20000, 40000).should.equal(false);
imageOptimizer.gainIsEnough(20000, 19500).should.equal(false);
imageOptimizer.gainIsEnough(200, 100).should.equal(true);
imageOptimizer.gainIsEnough(2000, 1900).should.equal(false);
imageOptimizer.gainIsEnough(200000, 197000).should.equal(true);
});
});
+85 -4
View File
@@ -1,5 +1,7 @@
var should = require('chai').should();
var weightChecker = require('../../lib/tools/weightChecker/weightChecker');
var fs = require('fs');
var path = require('path');
describe('weightChecker', function() {
@@ -14,7 +16,8 @@ describe('weightChecker', function() {
Accept: '*/*'
},
status: 200,
isHTML: true
isHTML: true,
type: 'html'
},
{
method: 'GET',
@@ -25,7 +28,34 @@ describe('weightChecker', function() {
Accept: '*/*'
},
status: 200,
isJS: true
isJS: true,
type: 'js'
},
{
method: 'GET',
url: 'http://localhost:8388/jpeg-image.jpg',
requestHeaders: {
'User-Agent': 'something',
Referer: 'http://www.google.fr/',
Accept: '*/*'
},
status: 200,
isImage: true,
type: 'image',
contentType: 'image/jpeg'
},
{
method: 'GET',
url: 'http://localhost:8388/svg-image.svg',
requestHeaders: {
'User-Agent': 'something',
Referer: 'http://www.google.fr/',
Accept: '*/*'
},
status: 200,
isImage: true,
type: 'image',
contentType: 'image/svg+xml'
}
];
@@ -48,6 +78,18 @@ describe('weightChecker', function() {
data.toolsResults.should.have.a.property('weightChecker');
data.toolsResults.weightChecker.should.have.a.property('metrics');
data.toolsResults.weightChecker.should.have.a.property('offenders');
data.toolsResults.weightChecker.offenders.should.have.a.property('totalWeight');
data.toolsResults.weightChecker.offenders.totalWeight.totalWeight.should.be.above(0);
data.toolsResults.weightChecker.offenders.totalWeight.byType.html.requests.length.should.equal(1);
data.toolsResults.weightChecker.offenders.totalWeight.byType.js.requests.length.should.equal(1);
data.toolsResults.weightChecker.offenders.totalWeight.byType.image.requests.length.should.equal(2);
data.toolsResults.weightChecker.offenders.should.have.a.property('imageOptimization');
data.toolsResults.weightChecker.offenders.imageOptimization.totalGain.should.be.above(0);
data.toolsResults.weightChecker.offenders.imageOptimization.byType.jpeg.requests.length.should.equal(1);
data.toolsResults.weightChecker.offenders.imageOptimization.byType.svg.requests.length.should.equal(1);
done();
})
@@ -77,7 +119,7 @@ describe('weightChecker', function() {
newEntry.weightCheck.uncompressedSize.should.equal(newEntry.weightCheck.bodySize);
newEntry.weightCheck.isCompressed.should.equal(false);
newEntry.weightCheck.headersSize.should.be.above(200).and.below(400);
newEntry.weightCheck.body.should.have.string('1.8.3');
newEntry.weightCheck.body.toString().should.have.string('1.8.3');
done();
})
@@ -87,6 +129,45 @@ describe('weightChecker', function() {
});
});
it('should download a PNG image and find the same body as fs.readFile', function(done) {
var fileContent = fs.readFileSync(path.resolve(__dirname, '../www/logo-large.png'));
var entry = {
method: 'GET',
url: 'http://localhost:8388/logo-large.png',
requestHeaders: {
'User-Agent': 'something',
Referer: 'http://www.google.fr/',
Accept: '*/*'
},
status: 200,
isImage: true,
contentType: 'image/png'
};
weightChecker.redownloadEntry(entry)
.then(function(newEntry) {
newEntry.weightCheck.bodySize.should.equal(4193);
newEntry.weightCheck.body.should.equal(fileContent.toString('binary'));
// Opening the image in lwip to check if the format is good
var lwip = require('lwip');
var buffer = new Buffer(newEntry.weightCheck.body, 'binary');
lwip.open(buffer, 'png', function(err, image) {
image.width().should.equal(620);
image.height().should.equal(104);
done(err);
});
})
.fail(function(err) {
done(err);
});
});
it('should fail downloading a file in error', function(done) {
var entry = {
method: 'GET',
@@ -103,7 +184,7 @@ describe('weightChecker', function() {
weightChecker.redownloadEntry(entry)
.then(function(errnewEntry) {
.then(function(newEntry) {
newEntry.weightCheck.should.have.a.property('message').that.equals('error while downloading: 404');
done();

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 84 KiB