UNPKG

8.62 kBJavaScriptView Raw
1// Load in our dependencies
2var assert = require('assert');
3var fs = require('fs');
4var path = require('path');
5var async = require('async');
6var gm = require('gm').subClass({imageMagick: true});
7var bufferedSpawn = require('buffered-spawn');
8var mkdirp = require('mkdirp');
9var tmp = require('tmp');
10
11// DEV: If we want to restructure away from a class
12// I (@twolfson) suggest writing to `exports` (e.g. `exports.getBooleanResult`, `exports.getFullResult`)
13// then making `module.exports = _.extend(exports.getBooleanResult, exports);`
14
15// Define custom resize function
16function transparentExtent(gm, params) {
17 // Assert we received our parameters
18 assert.notEqual(params.width, undefined);
19 assert.notEqual(params.height, undefined);
20
21 // Fill in new space with white background
22 // TODO: Parameterize background color (should be considered 'transparent' color in this case)
23 gm.background('transparent');
24
25 // Anchor image to upper-left
26 // TODO: Parameterize anchor point
27 gm.gravity('NorthWest');
28
29 // Specify new image size
30 gm.extent(params.width, params.height);
31
32 // Return gm instance for a fluent interface
33 return gm;
34}
35
36function ImageDiff() {
37}
38ImageDiff.getImageSize = function (filepath, cb) {
39 // TODO: This could be done via pngjs but stick to imagemagick for now
40 fs.stat(filepath, function (err, stats) {
41 // If the file does not exist, callback with info
42 if (err) {
43 if (err.code === 'ENOENT') {
44 return cb(null, null);
45 } else {
46 return cb(err);
47 }
48 }
49
50 gm(filepath).size(function (err, value) {
51 if (err) {
52 return cb(err);
53 }
54 cb(null, value);
55 });
56 });
57};
58ImageDiff.extractDifference = function (output) {
59 // Attempt to find variant between 'all: 0 (0)', 'all: 40131.8 (0.612372)', or 'all: 0.460961 (7.03381e-06)'
60 // DEV: According to http://www.imagemagick.org/discourse-server/viewtopic.php?f=1&t=17284
61 // DEV: These values are the total square root mean square (RMSE) pixel difference across all pixels and its percentage
62 // TODO: This is not very multi-lengual =(
63 var resultInfo = output.match(/all: (\d+(?:\.\d+)?(?:[Ee]-?\d+)?) \((\d+(?:\.\d+)?(?:[Ee]-?\d+)?)\)/);
64 if (!resultInfo) {
65 throw new Error('Expected output to contain \'all\' but received "' + output + '"');
66 }
67 return {
68 total: parseFloat(resultInfo[1], 10),
69 percentage: parseFloat(resultInfo[2], 10)
70 };
71};
72ImageDiff.createDiff = function (options, cb) {
73 // http://www.imagemagick.org/script/compare.php
74 var diffCmd = 'compare';
75 var diffArgs = [
76 '-verbose',
77 // TODO: metric and highlight could become constructor options
78 '-metric', 'RMSE',
79 '-highlight-color', 'RED']
80 // Shadow options if options.shadow is set
81 .concat(options.shadow ? [] : ['-compose', 'Src'])
82 // Paths to actual, expected, and diff images
83 .concat([
84 options.actualPath,
85 options.expectedPath,
86 // If there is no output image, then output to `stdout` (which is ignored)
87 options.diffPath || '-'
88 ]);
89 // Ignore `stdin` and `stdout` (useful for ignoring when images are being sent to stdout)
90 var spawnOptions = {stdio: ['ignore', 'ignore', 'pipe']};
91 bufferedSpawn(diffCmd, diffArgs, spawnOptions, function processDiffOutput (err, stdout, stderr) {
92 // If we failed with no info, callback
93 if (err && !stderr) {
94 return cb(err);
95 }
96
97 // Callback with raw result
98 return cb(null, stderr);
99 });
100};
101ImageDiff.prototype = {
102 rawDiff: function (options, callback) {
103 // TODO: Break this down more...
104 var actualPath = options.actualImage;
105 var expectedPath = options.expectedImage;
106 var diffPath = options.diffImage;
107 var shadow = options.shadow;
108
109 // Assert our options are passed in
110 if (!actualPath) {
111 return process.nextTick(function () {
112 callback(new Error('`options.actualPath` was not passed to `image-diff`'));
113 });
114 }
115 if (!expectedPath) {
116 return process.nextTick(function () {
117 callback(new Error('`options.expectedPath` was not passed to `image-diff`'));
118 });
119 }
120
121 var actualTmpPath;
122 var expectedTmpPath;
123 var rawResult;
124 async.waterfall([
125 function assertActualPathExists (cb) {
126 fs.exists(actualPath, function handleActualExists (actualExists) {
127 if (actualExists) {
128 cb();
129 } else {
130 cb(new Error('`image-diff` expected "' + actualPath + '" to exist but it didn\'t'));
131 }
132 });
133 },
134 function collectImageSizes (cb) {
135 // Collect the images sizes
136 async.map([actualPath, expectedPath], ImageDiff.getImageSize, cb);
137 },
138 function resizeImages (sizes, cb) {
139 // Find the maximum dimensions
140 var actualSize = sizes[0];
141 var expectedSize = sizes[1] || {doesNotExist: true, height: 0, width: 0};
142 var maxHeight = Math.max(actualSize.height, expectedSize.height);
143 var maxWidth = Math.max(actualSize.width, expectedSize.width);
144
145 // Resize both images
146 async.parallel([
147 function resizeActualImage (cb) {
148 // Get a temporary filepath
149 tmp.tmpName({postfix: '.png'}, function (err, filepath) {
150 // If there was an error, callback
151 if (err) {
152 return cb(err);
153 }
154
155 // Otherwise, resize the image
156 actualTmpPath = filepath;
157 transparentExtent(gm(actualPath), {
158 width: maxWidth,
159 height: maxHeight
160 }).write(actualTmpPath, cb);
161 });
162 },
163 function resizeExpectedImage (cb) {
164 tmp.tmpName({postfix: '.png'}, function (err, filepath) {
165 // If there was an error, callback
166 if (err) {
167 return cb(err);
168 }
169
170 // If there was no expected image, create a transparent image to compare against
171 expectedTmpPath = filepath;
172 if (expectedSize.doesNotExist) {
173 gm(maxWidth, maxHeight, 'transparent').write(expectedTmpPath, cb);
174 // Otherwise, resize the image
175 } else {
176 transparentExtent(gm(expectedPath), {
177 width: maxWidth,
178 height: maxHeight
179 }).write(expectedTmpPath, cb);
180 }
181 });
182 }
183 ], cb);
184 },
185 function createDiffDirectory (/*..., cb*/) {
186 var cb = [].slice.call(arguments, -1)[0];
187 if (diffPath) {
188 mkdirp(path.dirname(diffPath), function (err) {
189 cb(err);
190 });
191 } else {
192 process.nextTick(cb);
193 }
194 },
195 function createDiff (cb) {
196 ImageDiff.createDiff({
197 actualPath: actualTmpPath,
198 expectedPath: expectedTmpPath,
199 diffPath: diffPath,
200 shadow: shadow
201 }, function saveResult (err, _rawResult) {
202 rawResult = _rawResult;
203 cb(err);
204 });
205 }
206 ], function cleanup (err) {
207 // Clean up the temporary files
208 var cleanupPaths = [actualTmpPath, expectedTmpPath].filter(function (filepath) {
209 return !!filepath;
210 });
211 async.forEach(cleanupPaths, function cleanupFile (filepath, cb) {
212 fs.unlink(filepath, cb);
213 }, function callOriginalCallback (_err) {
214 // Callback with the raw result
215 callback(err, rawResult);
216 });
217 });
218 },
219 fullDiff: function (options, cb) {
220 // Create a raw diff
221 this.rawDiff(options, function handleRawDiff (err, rawResult) {
222 // If there was an error, callback with it
223 if (err) {
224 return cb(err);
225 }
226
227 // Otherwise, parse the result and callback
228 cb(null, ImageDiff.extractDifference(rawResult));
229 });
230 },
231 diff: function (options, cb) {
232 // Create a full diff
233 this.fullDiff(options, function handleFullDiff (err, difference) {
234 // If there was an error, callback with it
235 if (err) {
236 return cb(err);
237 }
238
239 // Otherwise, validate the difference and callback
240 cb(null, difference.total === 0);
241 });
242 }
243};
244
245// Create helper utilities
246function imageDiff(options, cb) {
247 var differ = new ImageDiff();
248 differ.diff(options, cb);
249}
250imageDiff.getFullResult = function (options, cb) {
251 var differ = new ImageDiff();
252 differ.fullDiff(options, cb);
253};
254imageDiff.getRawResult = function (options, cb) {
255 var differ = new ImageDiff();
256 differ.rawDiff(options, cb);
257};
258
259// Export the original class and helper
260imageDiff.ImageDiff = ImageDiff;
261module.exports = imageDiff;