/* James Cryer / Huddle URL: https://github.com/Huddle/Resemble.js */ (function(root, factory) { "use strict"; if (typeof define === "function" && define.amd) { define([], factory); } else if (typeof module === "object" && module.exports) { module.exports = factory(); } else { root.resemble = factory(); } })(this, function() { "use strict"; var Img; var Canvas; if (typeof Image !== "undefined") { Img = Image; } else { Canvas = require("canvas-prebuilt"); // eslint-disable-line global-require Img = Canvas.Image; } var document = typeof window !== "undefined" ? window.document : { createElement: function() { // This will work as long as only createElement is used on window.document return new Canvas(); } }; var oldGlobalSettings = {}; var globalOutputSettings = oldGlobalSettings; function setGlobalOutputSettings(settings) { var msg = "warning resemble.outputSettings mutates global state, and " + "will be removed in 3.0.0"; console.warn(msg); globalOutputSettings = settings; return this; } var resemble = function(fileData) { var pixelTransparency = 1; var errorPixelColor = { // Color for Error Pixels. Between 0 and 255. red: 255, green: 0, blue: 255, alpha: 255 }; var targetPix = { r: 0, g: 0, b: 0, a: 0 }; // isAntialiased function colorsDistance(c1, c2) { return ( (Math.abs(c1.r - c2.r) + Math.abs(c1.g - c2.g) + Math.abs(c1.b - c2.b)) / 3 ); } function withinBoundingBox(x, y, width, height, box) { return ( x > (box.left || 0) && x < (box.right || width) && y > (box.top || 0) && y < (box.bottom || height) ); } function withinComparedArea(x, y, width, height) { var isIncluded = true; if ( boundingBox !== undefined && !withinBoundingBox(x, y, width, height, boundingBox) ) { isIncluded = false; } if ( ignoredBox !== undefined && withinBoundingBox(x, y, width, height, ignoredBox) ) { isIncluded = false; } return isIncluded; } var errorPixelTransform = { flat: function(px, offset) { px[offset] = errorPixelColor.red; px[offset + 1] = errorPixelColor.green; px[offset + 2] = errorPixelColor.blue; px[offset + 3] = errorPixelColor.alpha; }, movement: function(px, offset, d1, d2) { px[offset] = (d2.r * (errorPixelColor.red / 255) + errorPixelColor.red) / 2; px[offset + 1] = (d2.g * (errorPixelColor.green / 255) + errorPixelColor.green) / 2; px[offset + 2] = (d2.b * (errorPixelColor.blue / 255) + errorPixelColor.blue) / 2; px[offset + 3] = d2.a; }, flatDifferenceIntensity: function(px, offset, d1, d2) { px[offset] = errorPixelColor.red; px[offset + 1] = errorPixelColor.green; px[offset + 2] = errorPixelColor.blue; px[offset + 3] = colorsDistance(d1, d2); }, movementDifferenceIntensity: function(px, offset, d1, d2) { var ratio = colorsDistance(d1, d2) / 255 * 0.8; px[offset] = (1 - ratio) * (d2.r * (errorPixelColor.red / 255)) + ratio * errorPixelColor.red; px[offset + 1] = (1 - ratio) * (d2.g * (errorPixelColor.green / 255)) + ratio * errorPixelColor.green; px[offset + 2] = (1 - ratio) * (d2.b * (errorPixelColor.blue / 255)) + ratio * errorPixelColor.blue; px[offset + 3] = d2.a; }, diffOnly: function(px, offset, d1, d2) { px[offset] = d2.r; px[offset + 1] = d2.g; px[offset + 2] = d2.b; px[offset + 3] = d2.a; } }; var errorPixel = errorPixelTransform.flat; var errorType; var boundingBox; var ignoredBox; var largeImageThreshold = 1200; var useCrossOrigin = true; var data = {}; var images = []; var updateCallbackArray = []; var tolerance = { // between 0 and 255 red: 16, green: 16, blue: 16, alpha: 16, minBrightness: 16, maxBrightness: 240 }; var ignoreAntialiasing = false; var ignoreColors = false; var scaleToSameSize = false; function triggerDataUpdate() { var len = updateCallbackArray.length; var i; for (i = 0; i < len; i++) { if (typeof updateCallbackArray[i] === "function") { updateCallbackArray[i](data); } } } function loop(w, h, callback) { var x; var y; for (x = 0; x < w; x++) { for (y = 0; y < h; y++) { callback(x, y); } } } function parseImage(sourceImageData, width, height) { var pixelCount = 0; var redTotal = 0; var greenTotal = 0; var blueTotal = 0; var alphaTotal = 0; var brightnessTotal = 0; var whiteTotal = 0; var blackTotal = 0; loop(width, height, function(horizontalPos, verticalPos) { var offset = (verticalPos * width + horizontalPos) * 4; var red = sourceImageData[offset]; var green = sourceImageData[offset + 1]; var blue = sourceImageData[offset + 2]; var alpha = sourceImageData[offset + 3]; var brightness = getBrightness(red, green, blue); if (red === green && red === blue && alpha) { if (red === 0) { blackTotal++; } else if (red === 255) { whiteTotal++; } } pixelCount++; redTotal += red / 255 * 100; greenTotal += green / 255 * 100; blueTotal += blue / 255 * 100; alphaTotal += (255 - alpha) / 255 * 100; brightnessTotal += brightness / 255 * 100; }); data.red = Math.floor(redTotal / pixelCount); data.green = Math.floor(greenTotal / pixelCount); data.blue = Math.floor(blueTotal / pixelCount); data.alpha = Math.floor(alphaTotal / pixelCount); data.brightness = Math.floor(brightnessTotal / pixelCount); data.white = Math.floor(whiteTotal / pixelCount * 100); data.black = Math.floor(blackTotal / pixelCount * 100); triggerDataUpdate(); } function loadImageData(fileDataForImage, callback) { var fileReader; var hiddenImage = new Img(); if (!hiddenImage.setAttribute) { hiddenImage.setAttribute = function setAttribute() {}; } if (useCrossOrigin) { hiddenImage.setAttribute("crossorigin", "anonymous"); } hiddenImage.onerror = function(err) { hiddenImage.onload = null; hiddenImage.onerror = null; // fixes pollution between calls images.push({ error: err ? err + "" : "Image load error." }); callback(); }; hiddenImage.onload = function() { hiddenImage.onload = null; // fixes pollution between calls hiddenImage.onerror = null; var hiddenCanvas = document.createElement("canvas"); var imageData; // don't assign to hiddenImage, see https://github.com/Huddle/Resemble.js/pull/87/commits/300d43352a2845aad289b254bfbdc7cd6a37e2d7 var width = hiddenImage.width; var height = hiddenImage.height; if (scaleToSameSize && images.length === 1) { width = images[0].width; height = images[0].height; } hiddenCanvas.width = width; hiddenCanvas.height = height; hiddenCanvas .getContext("2d") .drawImage(hiddenImage, 0, 0, width, height); imageData = hiddenCanvas .getContext("2d") .getImageData(0, 0, width, height); images.push(imageData); callback(imageData, width, height); }; if (typeof fileDataForImage === "string") { hiddenImage.src = fileDataForImage; if (hiddenImage.complete && hiddenImage.naturalWidth > 0) { hiddenImage.onload(); } } else if ( typeof fileDataForImage.data !== "undefined" && typeof fileDataForImage.width === "number" && typeof fileDataForImage.height === "number" ) { images.push(fileDataForImage); callback( fileDataForImage, fileDataForImage.width, fileDataForImage.height ); } else if ( typeof Buffer !== "undefined" && fileDataForImage instanceof Buffer ) { // If we have Buffer, assume we're on Node+Canvas and its supported hiddenImage.src = fileDataForImage; } else { fileReader = new FileReader(); fileReader.onload = function(event) { hiddenImage.src = event.target.result; }; fileReader.readAsDataURL(fileDataForImage); } } function isColorSimilar(a, b, color) { var absDiff = Math.abs(a - b); if (typeof a === "undefined") { return false; } if (typeof b === "undefined") { return false; } if (a === b) { return true; } else if (absDiff < tolerance[color]) { return true; } return false; } function isPixelBrightnessSimilar(d1, d2) { var alpha = isColorSimilar(d1.a, d2.a, "alpha"); var brightness = isColorSimilar( d1.brightness, d2.brightness, "minBrightness" ); return brightness && alpha; } function getBrightness(r, g, b) { return 0.3 * r + 0.59 * g + 0.11 * b; } function isRGBSame(d1, d2) { var red = d1.r === d2.r; var green = d1.g === d2.g; var blue = d1.b === d2.b; return red && green && blue; } function isRGBSimilar(d1, d2) { var red = isColorSimilar(d1.r, d2.r, "red"); var green = isColorSimilar(d1.g, d2.g, "green"); var blue = isColorSimilar(d1.b, d2.b, "blue"); var alpha = isColorSimilar(d1.a, d2.a, "alpha"); return red && green && blue && alpha; } function isContrasting(d1, d2) { return ( Math.abs(d1.brightness - d2.brightness) > tolerance.maxBrightness ); } function getHue(red, green, blue) { var r = red / 255; var g = green / 255; var b = blue / 255; var max = Math.max(r, g, b); var min = Math.min(r, g, b); var h; var d; if (max === min) { h = 0; // achromatic } else { d = max - min; switch (max) { case r: h = (g - b) / d + (g < b ? 6 : 0); break; case g: h = (b - r) / d + 2; break; case b: h = (r - g) / d + 4; break; default: h /= 6; } } return h; } function isAntialiased( sourcePix, pix, cacheSet, verticalPos, horizontalPos, width ) { var offset; var distance = 1; var i; var j; var hasHighContrastSibling = 0; var hasSiblingWithDifferentHue = 0; var hasEquivalentSibling = 0; addHueInfo(sourcePix); for (i = distance * -1; i <= distance; i++) { for (j = distance * -1; j <= distance; j++) { if (i === 0 && j === 0) { // ignore source pixel } else { offset = ((verticalPos + j) * width + (horizontalPos + i)) * 4; if (!getPixelInfo(targetPix, pix, offset, cacheSet)) { continue; } addBrightnessInfo(targetPix); addHueInfo(targetPix); if (isContrasting(sourcePix, targetPix)) { hasHighContrastSibling++; } if (isRGBSame(sourcePix, targetPix)) { hasEquivalentSibling++; } if (Math.abs(targetPix.h - sourcePix.h) > 0.3) { hasSiblingWithDifferentHue++; } if ( hasSiblingWithDifferentHue > 1 || hasHighContrastSibling > 1 ) { return true; } } } } if (hasEquivalentSibling < 2) { return true; } return false; } function copyPixel(px, offset, pix) { if (errorType === "diffOnly") { return; } px[offset] = pix.r; // r px[offset + 1] = pix.g; // g px[offset + 2] = pix.b; // b px[offset + 3] = pix.a * pixelTransparency; // a } function copyGrayScalePixel(px, offset, pix) { if (errorType === "diffOnly") { return; } px[offset] = pix.brightness; // r px[offset + 1] = pix.brightness; // g px[offset + 2] = pix.brightness; // b px[offset + 3] = pix.a * pixelTransparency; // a } function getPixelInfo(dst, pix, offset) { if (pix.length > offset) { dst.r = pix[offset]; dst.g = pix[offset + 1]; dst.b = pix[offset + 2]; dst.a = pix[offset + 3]; return true; } return false; } function addBrightnessInfo(pix) { pix.brightness = getBrightness(pix.r, pix.g, pix.b); // 'corrected' lightness } function addHueInfo(pix) { pix.h = getHue(pix.r, pix.g, pix.b); } function analyseImages(img1, img2, width, height) { var hiddenCanvas = document.createElement("canvas"); var data1 = img1.data; var data2 = img2.data; hiddenCanvas.width = width; hiddenCanvas.height = height; var context = hiddenCanvas.getContext("2d"); var imgd = context.createImageData(width, height); var pix = imgd.data; var mismatchCount = 0; var diffBounds = { top: height, left: width, bottom: 0, right: 0 }; var updateBounds = function(x, y) { diffBounds.left = Math.min(x, diffBounds.left); diffBounds.right = Math.max(x, diffBounds.right); diffBounds.top = Math.min(y, diffBounds.top); diffBounds.bottom = Math.max(y, diffBounds.bottom); }; var time = Date.now(); var skip; if ( !!largeImageThreshold && ignoreAntialiasing && (width > largeImageThreshold || height > largeImageThreshold) ) { skip = 6; } var pixel1 = { r: 0, g: 0, b: 0, a: 0 }; var pixel2 = { r: 0, g: 0, b: 0, a: 0 }; loop(width, height, function(horizontalPos, verticalPos) { if (skip) { // only skip if the image isn't small if ( verticalPos % skip === 0 || horizontalPos % skip === 0 ) { return; } } var offset = (verticalPos * width + horizontalPos) * 4; var isWithinComparedArea = withinComparedArea( horizontalPos, verticalPos, width, height ); if ( !getPixelInfo(pixel1, data1, offset, 1) || !getPixelInfo(pixel2, data2, offset, 2) ) { return; } if (ignoreColors) { addBrightnessInfo(pixel1); addBrightnessInfo(pixel2); if ( isPixelBrightnessSimilar(pixel1, pixel2) || !isWithinComparedArea ) { copyGrayScalePixel(pix, offset, pixel2); } else { errorPixel(pix, offset, pixel1, pixel2); mismatchCount++; updateBounds(horizontalPos, verticalPos); } return; } if (isRGBSimilar(pixel1, pixel2) || !isWithinComparedArea) { copyPixel(pix, offset, pixel1); } else if ( ignoreAntialiasing && (addBrightnessInfo(pixel1), // jit pixel info augmentation looks a little weird, sorry. addBrightnessInfo(pixel2), isAntialiased( pixel1, data1, 1, verticalPos, horizontalPos, width ) || isAntialiased( pixel2, data2, 2, verticalPos, horizontalPos, width )) ) { if ( isPixelBrightnessSimilar(pixel1, pixel2) || !isWithinComparedArea ) { copyGrayScalePixel(pix, offset, pixel2); } else { errorPixel(pix, offset, pixel1, pixel2); mismatchCount++; updateBounds(horizontalPos, verticalPos); } } else { errorPixel(pix, offset, pixel1, pixel2); mismatchCount++; updateBounds(horizontalPos, verticalPos); } }); data.rawMisMatchPercentage = mismatchCount / (height * width) * 100; data.misMatchPercentage = data.rawMisMatchPercentage.toFixed(2); data.diffBounds = diffBounds; data.analysisTime = Date.now() - time; data.getImageDataUrl = function(text) { var barHeight = 0; if (text) { barHeight = addLabel(text, context, hiddenCanvas); } context.putImageData(imgd, 0, barHeight); return hiddenCanvas.toDataURL("image/png"); }; if (hiddenCanvas.toBuffer) { data.getBuffer = function(includeOriginal) { if (includeOriginal) { var imageWidth = hiddenCanvas.width + 2; hiddenCanvas.width = imageWidth * 3; context.putImageData(img1, 0, 0); context.putImageData(img2, imageWidth, 0); context.putImageData(imgd, imageWidth * 2, 0); } else { context.putImageData(imgd, 0, 0); } return hiddenCanvas.toBuffer(); }; } } function addLabel(text, context, hiddenCanvas) { var textPadding = 2; context.font = "12px sans-serif"; var textWidth = context.measureText(text).width + textPadding * 2; var barHeight = 22; if (textWidth > hiddenCanvas.width) { hiddenCanvas.width = textWidth; } hiddenCanvas.height += barHeight; context.fillStyle = "#666"; context.fillRect(0, 0, hiddenCanvas.width, barHeight - 4); context.fillStyle = "#fff"; context.fillRect(0, barHeight - 4, hiddenCanvas.width, 4); context.fillStyle = "#fff"; context.textBaseline = "top"; context.font = "12px sans-serif"; context.fillText(text, textPadding, 1); return barHeight; } function normalise(img, w, h) { var c; var context; if (img.height < h || img.width < w) { c = document.createElement("canvas"); c.width = w; c.height = h; context = c.getContext("2d"); context.putImageData(img, 0, 0); return context.getImageData(0, 0, w, h); } return img; } function outputSettings(options) { var key; if (options.errorColor) { for (key in options.errorColor) { if (options.errorColor.hasOwnProperty(key)) { errorPixelColor[key] = options.errorColor[key] === void 0 ? errorPixelColor[key] : options.errorColor[key]; } } } if (options.errorType && errorPixelTransform[options.errorType]) { errorPixel = errorPixelTransform[options.errorType]; errorType = options.errorType; } if ( options.errorPixel && typeof options.errorPixel === "function" ) { errorPixel = options.errorPixel; } pixelTransparency = isNaN(Number(options.transparency)) ? pixelTransparency : options.transparency; if (options.largeImageThreshold !== undefined) { largeImageThreshold = options.largeImageThreshold; } if (options.useCrossOrigin !== undefined) { useCrossOrigin = options.useCrossOrigin; } if (options.boundingBox !== undefined) { boundingBox = options.boundingBox; } if (options.ignoredBox !== undefined) { ignoredBox = options.ignoredBox; } } function compare(one, two) { if (globalOutputSettings !== oldGlobalSettings) { outputSettings(globalOutputSettings); } function onceWeHaveBoth() { var width; var height; if (images.length === 2) { if (images[0].error || images[1].error) { data = {}; data.error = images[0].error ? images[0].error : images[1].error; triggerDataUpdate(); return; } width = images[0].width > images[1].width ? images[0].width : images[1].width; height = images[0].height > images[1].height ? images[0].height : images[1].height; if ( images[0].width === images[1].width && images[0].height === images[1].height ) { data.isSameDimensions = true; } else { data.isSameDimensions = false; } data.dimensionDifference = { width: images[0].width - images[1].width, height: images[0].height - images[1].height }; analyseImages( normalise(images[0], width, height), normalise(images[1], width, height), width, height ); triggerDataUpdate(); } } images = []; loadImageData(one, onceWeHaveBoth); loadImageData(two, onceWeHaveBoth); } function getCompareApi(param) { var secondFileData; var hasMethod = typeof param === "function"; if (!hasMethod) { // assume it's file data secondFileData = param; } var self = { scaleToSameSize: function() { scaleToSameSize = true; if (hasMethod) { param(); } return self; }, useOriginalSize: function() { scaleToSameSize = false; if (hasMethod) { param(); } return self; }, ignoreNothing: function() { tolerance.red = 0; tolerance.green = 0; tolerance.blue = 0; tolerance.alpha = 0; tolerance.minBrightness = 0; tolerance.maxBrightness = 255; ignoreAntialiasing = false; ignoreColors = false; if (hasMethod) { param(); } return self; }, ignoreLess: function() { tolerance.red = 16; tolerance.green = 16; tolerance.blue = 16; tolerance.alpha = 16; tolerance.minBrightness = 16; tolerance.maxBrightness = 240; ignoreAntialiasing = false; ignoreColors = false; if (hasMethod) { param(); } return self; }, ignoreAntialiasing: function() { tolerance.red = 32; tolerance.green = 32; tolerance.blue = 32; tolerance.alpha = 32; tolerance.minBrightness = 64; tolerance.maxBrightness = 96; ignoreAntialiasing = true; ignoreColors = false; if (hasMethod) { param(); } return self; }, ignoreColors: function() { tolerance.alpha = 16; tolerance.minBrightness = 16; tolerance.maxBrightness = 240; ignoreAntialiasing = false; ignoreColors = true; if (hasMethod) { param(); } return self; }, ignoreAlpha: function() { tolerance.red = 16; tolerance.green = 16; tolerance.blue = 16; tolerance.alpha = 255; tolerance.minBrightness = 16; tolerance.maxBrightness = 240; ignoreAntialiasing = false; ignoreColors = false; if (hasMethod) { param(); } return self; }, repaint: function() { if (hasMethod) { param(); } return self; }, outputSettings: function(options) { outputSettings(options); return self; }, onComplete: function(callback) { updateCallbackArray.push(callback); var wrapper = function() { compare(fileData, secondFileData); }; wrapper(); return getCompareApi(wrapper); } }; return self; } var rootSelf = { onComplete: function(callback) { updateCallbackArray.push(callback); loadImageData(fileData, function(imageData, width, height) { parseImage(imageData.data, width, height); }); }, compareTo: function(secondFileData) { return getCompareApi(secondFileData); }, outputSettings: function(options) { outputSettings(options); return rootSelf; } }; return rootSelf; }; function applyIgnore(api, ignore) { switch (ignore) { case "nothing": api.ignoreNothing(); break; case "less": api.ignoreLess(); break; case "antialiasing": api.ignoreAntialiasing(); break; case "colors": api.ignoreColors(); break; case "alpha": api.ignoreAlpha(); break; default: throw new Error("Invalid ignore: " + ignore); } } resemble.compare = function(image1, image2, options, cb) { var callback; var opt; if (typeof options === "function") { callback = options; opt = {}; } else { callback = cb; opt = options || {}; } var res = resemble(image1); var compare; if (opt.output) { res.outputSettings(opt.output); } compare = res.compareTo(image2); if (opt.scaleToSameSize) { compare.scaleToSameSize(); } if (typeof opt.ignore === "string") { applyIgnore(compare, opt.ignore); } else if (opt.ignore && opt.ignore.forEach) { opt.ignore.forEach(function(v) { applyIgnore(compare, v); }); } compare.onComplete(function(data) { if (data.error) { callback(data.error); } else { callback(null, data); } }); }; resemble.outputSettings = setGlobalOutputSettings; return resemble; });