UNPKG

7.35 kBJavaScriptView Raw
1var exec = require('child_process').exec;
2var fs = require('fs');
3var path = require('path');
4var async = require('async');
5var gm = require('gm').subClass({imageMagick: true});
6var shellQuote = require('shell-quote');
7var mkdirp = require('mkdirp');
8var tmp = require('tmp');
9
10// Define custom resize function
11// Taken from https://github.com/twolfson/twolfson.com/blob/3.4.0/test/perceptual-tests/twolfson.com_test.js#L88-L107
12// TODO: Make image resizing its own library
13// DEV: This does not pollute gm.prototype
14gm.prototype.fillFromTo = function (params) {
15 // Fill in new space with white background
16 // TODO: Parameterize background color (should be considered 'transparent' color in this case)
17 this.borderColor('transparent');
18 this.border(Math.max(params.toWidth - params.fromWidth, 0), Math.max(params.toHeight - params.fromHeight, 0));
19
20 // Anchor image to upper-left
21 // TODO: Parameterize anchor point
22 this.gravity('SouthEast');
23
24 // Specify new image size
25 this.crop(params.toWidth, params.toHeight, 0, 0);
26
27 // Return this instance
28 return this;
29};
30
31function ImageDiff() {
32}
33ImageDiff.getImageSize = function (filepath, cb) {
34 // TODO: This could be done via pngjs but stick to imagemagick for now
35 fs.stat(filepath, function (err, stats) {
36 // If the file does not exist, callback with info
37 if (err) {
38 if (err.code === 'ENOENT') {
39 return cb(null, null);
40 } else {
41 return cb(err);
42 }
43 }
44
45 gm(filepath).size(function (err, value) {
46 if (err) {
47 return cb(err);
48 }
49 cb(null, value);
50 });
51 });
52};
53ImageDiff.createDiff = function (options, cb) {
54 // http://www.imagemagick.org/script/compare.php
55 var diffCmd = shellQuote.quote([
56 'compare',
57 '-verbose',
58 // TODO: metric and highlight could become constructor options
59 '-metric', 'RMSE',
60 '-highlight-color', 'RED',
61 '-compose', 'Src',
62 options.actualPath,
63 options.expectedPath,
64 options.diffPath
65 ]);
66 exec(diffCmd, function processDiffOutput (err, stdout, stderr) {
67 // If we failed with no info, callback
68 if (err && !stderr) {
69 return cb(err);
70 }
71
72 // Attempt to find variant between 'all: 0 (0)' or 'all: 40131.8 (0.612372)'
73 // DEV: According to http://www.imagemagick.org/discourse-server/viewtopic.php?f=1&t=17284
74 // DEV: These values are the total square root mean square (RMSE) pixel difference across all pixels and its percentage
75 // TODO: This is not very multi-lengual =(
76 var resultInfo = stderr.match(/all: (\d+\.?\d*) \((\d+\.?\d*)\)/);
77
78 // If there was no resultInfo, throw a fit
79 if (!resultInfo) {
80 return cb(new Error('Expected `image-diff\'s stderr` to contain \'all\' but received "' + stderr + '"'));
81 }
82
83 // Callback with pass/fail
84 var totalDifference = resultInfo[1];
85 return cb(null, totalDifference === '0');
86 });
87};
88ImageDiff.prototype = {
89 diff: function (options, callback) {
90 // TODO: Break this down more...
91 var actualPath = options.actualImage;
92 var expectedPath = options.expectedImage;
93 var diffPath = options.diffImage;
94
95 // Assert our options are passed in
96 if (!actualPath) {
97 return process.nextTick(function () {
98 callback(new Error('`options.actualPath` was not passed to `image-diff`'));
99 });
100 }
101 if (!expectedPath) {
102 return process.nextTick(function () {
103 callback(new Error('`options.expectedPath` was not passed to `image-diff`'));
104 });
105 }
106 if (!diffPath) {
107 return process.nextTick(function () {
108 callback(new Error('`options.diffPath` was not passed to `image-diff`'));
109 });
110 }
111
112 var actualTmpPath;
113 var expectedTmpPath;
114 var imagesAreSame;
115 async.waterfall([
116 function assertActualPathExists (cb) {
117 fs.exists(actualPath, function handleActualExists (actualExists) {
118 if (actualExists) {
119 cb();
120 } else {
121 cb(new Error('`image-diff` expected "' + actualPath + '" to exist but it didn\'t'));
122 }
123 });
124 },
125 function collectImageSizes (cb) {
126 // Collect the images sizes
127 async.map([actualPath, expectedPath], ImageDiff.getImageSize, cb);
128 },
129 function resizeImages (sizes, cb) {
130 // Find the maximum dimensions
131 var actualSize = sizes[0];
132 var expectedSize = sizes[1] || {doesNotExist: true, height: 0, width: 0};
133 var maxHeight = Math.max(actualSize.height, expectedSize.height);
134 var maxWidth = Math.max(actualSize.width, expectedSize.width);
135
136 // Resize both images
137 async.parallel([
138 function resizeActualImage (cb) {
139 // Get a temporary filepath
140 tmp.tmpName({postfix: '.png'}, function (err, filepath) {
141 // If there was an error, callback
142 if (err) {
143 return cb(err);
144 }
145
146 // Otherwise, resize the image
147 actualTmpPath = filepath;
148 gm(actualPath).fillFromTo({
149 fromWidth: actualSize.width,
150 fromHeight: actualSize.height,
151 toWidth: maxWidth,
152 toHeight: maxHeight
153 }).write(actualTmpPath, cb);
154 });
155 },
156 function resizeExpectedImage (cb) {
157 tmp.tmpName({postfix: '.png'}, function (err, filepath) {
158 // If there was an error, callback
159 if (err) {
160 return cb(err);
161 }
162
163 // If there was no expected image, create a transparent image to compare against
164 expectedTmpPath = filepath;
165 if (expectedSize.doesNotExist) {
166 gm(maxWidth, maxHeight, 'transparent').write(expectedTmpPath, cb);
167 // Otherwise, resize the image
168 } else {
169 gm(expectedPath).fillFromTo({
170 fromWidth: expectedSize.width,
171 fromHeight: expectedSize.height,
172 toWidth: maxWidth,
173 toHeight: maxHeight
174 }).write(expectedTmpPath, cb);
175 }
176 });
177 }
178 ], cb);
179 },
180 function createDiffDirectory (/*..., cb*/) {
181 var cb = [].slice.call(arguments, -1)[0];
182 mkdirp(path.dirname(diffPath), function (err) {
183 cb(err);
184 });
185 },
186 function createDiff (cb) {
187 ImageDiff.createDiff({
188 actualPath: actualTmpPath,
189 expectedPath: expectedTmpPath,
190 diffPath: diffPath
191 }, function saveResult (err, _imagesAreSame) {
192 imagesAreSame = _imagesAreSame;
193 cb(err);
194 });
195 }
196 ], function cleanup (err) {
197 // Clean up the temporary files
198 var cleanupPaths = [actualTmpPath, expectedTmpPath].filter(function (filepath) {
199 return !!filepath;
200 });
201 async.forEach(cleanupPaths, function cleanupFile (filepath, cb) {
202 fs.unlink(filepath, cb);
203 }, function callOriginalCallback (_err) {
204 // Callback with the imagesAreSame
205 callback(err, imagesAreSame);
206 });
207 });
208 }
209};
210
211// Create helper utility
212function diffImages(options, cb) {
213 var differ = new ImageDiff();
214 differ.diff(options, cb);
215}
216
217// Export the original class and helper
218diffImages.ImageDiff = ImageDiff;
219module.exports = diffImages;