UNPKG

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