UNPKG

@recreatejs/jasmine-pixelmatch

Version:

HTML5 canvas visual regression tests for Jasmine using pixelmatch

415 lines (337 loc) 14 kB
var jasminePixelmatch = (function (exports) { 'use strict'; function getCanvas(width, height) { var canvas = document.createElement("canvas"); if (width) canvas.width = width; if (height) canvas.height = height; return canvas; } function createCanvasFromImageData(imageData) { let canvas = getCanvas(imageData.width, imageData.height); let context = canvas.getContext("2d"); context.putImageData(imageData, 0, 0); return canvas; } /** globals jasmineRequire */ function buildError(message, actualCanvas, expectedCanvas = null, diffCanvas = null) { if (!isHTMLReport()) { let output = message; if (actualCanvas) { output += "\nActual: \n" + actualCanvas.toDataURL(); } if (expectedCanvas) { output += "\nExpected: \n" + expectedCanvas.toDataURL(); } if (diffCanvas) { output += "\nDiff: \n" + diffCanvas.toDataURL(); } return output; } let html = buildEl("div", message); styleCanvas(actualCanvas); let actualDiv = buildEl("div", "Actual:<br/> "); actualDiv.appendChild(actualCanvas); html.appendChild(actualDiv); if (expectedCanvas) { styleCanvas(expectedCanvas); let expectedDiv = buildEl("div", "Expected:<br/> "); expectedDiv.appendChild(expectedCanvas); html.appendChild(expectedDiv); } if (diffCanvas) { styleCanvas(diffCanvas); let diffDiv = buildEl("div", "Diff:<br/> "); diffDiv.appendChild(diffCanvas); html.appendChild(diffDiv); } return html; } function isHTMLReport() { return !!jasmineRequire.html; } function buildEl(element, content) { element = document.createElement(element); if (element && content) { element.innerHTML = content; } return element; } function styleCanvas(canvas) { canvas.style.border = "1px solid #ddd"; canvas.style.background = "white"; } var pixelmatch_1 = pixelmatch; const defaultOptions = { threshold: 0.1, // matching threshold (0 to 1); smaller is more sensitive includeAA: false, // whether to skip anti-aliasing detection alpha: 0.1, // opacity of original image in diff ouput aaColor: [255, 255, 0], // color of anti-aliased pixels in diff output diffColor: [255, 0, 0], // color of different pixels in diff output diffMask: false // draw the diff over a transparent background (a mask) }; function pixelmatch(img1, img2, output, width, height, options) { if (!isPixelData(img1) || !isPixelData(img2) || (output && !isPixelData(output))) throw new Error('Image data: Uint8Array, Uint8ClampedArray or Buffer expected.'); if (img1.length !== img2.length || (output && output.length !== img1.length)) throw new Error('Image sizes do not match.'); if (img1.length !== width * height * 4) throw new Error('Image data size does not match width/height.'); options = Object.assign({}, defaultOptions, options); // check if images are identical const len = width * height; const a32 = new Uint32Array(img1.buffer, img1.byteOffset, len); const b32 = new Uint32Array(img2.buffer, img2.byteOffset, len); let identical = true; for (let i = 0; i < len; i++) { if (a32[i] !== b32[i]) { identical = false; break; } } if (identical) { // fast path if identical if (output && !options.diffMask) { for (let i = 0; i < len; i++) drawGrayPixel(img1, 4 * i, options.alpha, output); } return 0; } // maximum acceptable square distance between two colors; // 35215 is the maximum possible value for the YIQ difference metric const maxDelta = 35215 * options.threshold * options.threshold; let diff = 0; const [aaR, aaG, aaB] = options.aaColor; const [diffR, diffG, diffB] = options.diffColor; // compare each pixel of one image against the other one for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const pos = (y * width + x) * 4; // squared YUV distance between colors at this pixel position const delta = colorDelta(img1, img2, pos, pos); // the color difference is above the threshold if (delta > maxDelta) { // check it's a real rendering difference or just anti-aliasing if (!options.includeAA && (antialiased(img1, x, y, width, height, img2) || antialiased(img2, x, y, width, height, img1))) { // one of the pixels is anti-aliasing; draw as yellow and do not count as difference // note that we do not include such pixels in a mask if (output && !options.diffMask) drawPixel(output, pos, aaR, aaG, aaB); } else { // found substantial difference not caused by anti-aliasing; draw it as red if (output) drawPixel(output, pos, diffR, diffG, diffB); diff++; } } else if (output) { // pixels are similar; draw background as grayscale image blended with white if (!options.diffMask) drawGrayPixel(img1, pos, options.alpha, output); } } } // return the number of different pixels return diff; } function isPixelData(arr) { // work around instanceof Uint8Array not working properly in some Jest environments return ArrayBuffer.isView(arr) && arr.constructor.BYTES_PER_ELEMENT === 1; } // check if a pixel is likely a part of anti-aliasing; // based on "Anti-aliased Pixel and Intensity Slope Detector" paper by V. Vysniauskas, 2009 function antialiased(img, x1, y1, width, height, img2) { const x0 = Math.max(x1 - 1, 0); const y0 = Math.max(y1 - 1, 0); const x2 = Math.min(x1 + 1, width - 1); const y2 = Math.min(y1 + 1, height - 1); const pos = (y1 * width + x1) * 4; let zeroes = x1 === x0 || x1 === x2 || y1 === y0 || y1 === y2 ? 1 : 0; let min = 0; let max = 0; let minX, minY, maxX, maxY; // go through 8 adjacent pixels for (let x = x0; x <= x2; x++) { for (let y = y0; y <= y2; y++) { if (x === x1 && y === y1) continue; // brightness delta between the center pixel and adjacent one const delta = colorDelta(img, img, pos, (y * width + x) * 4, true); // count the number of equal, darker and brighter adjacent pixels if (delta === 0) { zeroes++; // if found more than 2 equal siblings, it's definitely not anti-aliasing if (zeroes > 2) return false; // remember the darkest pixel } else if (delta < min) { min = delta; minX = x; minY = y; // remember the brightest pixel } else if (delta > max) { max = delta; maxX = x; maxY = y; } } } // if there are no both darker and brighter pixels among siblings, it's not anti-aliasing if (min === 0 || max === 0) return false; // if either the darkest or the brightest pixel has 3+ equal siblings in both images // (definitely not anti-aliased), this pixel is anti-aliased return (hasManySiblings(img, minX, minY, width, height) && hasManySiblings(img2, minX, minY, width, height)) || (hasManySiblings(img, maxX, maxY, width, height) && hasManySiblings(img2, maxX, maxY, width, height)); } // check if a pixel has 3+ adjacent pixels of the same color. function hasManySiblings(img, x1, y1, width, height) { const x0 = Math.max(x1 - 1, 0); const y0 = Math.max(y1 - 1, 0); const x2 = Math.min(x1 + 1, width - 1); const y2 = Math.min(y1 + 1, height - 1); const pos = (y1 * width + x1) * 4; let zeroes = x1 === x0 || x1 === x2 || y1 === y0 || y1 === y2 ? 1 : 0; // go through 8 adjacent pixels for (let x = x0; x <= x2; x++) { for (let y = y0; y <= y2; y++) { if (x === x1 && y === y1) continue; const pos2 = (y * width + x) * 4; if (img[pos] === img[pos2] && img[pos + 1] === img[pos2 + 1] && img[pos + 2] === img[pos2 + 2] && img[pos + 3] === img[pos2 + 3]) zeroes++; if (zeroes > 2) return true; } } return false; } // calculate color difference according to the paper "Measuring perceived color difference // using YIQ NTSC transmission color space in mobile applications" by Y. Kotsarenko and F. Ramos function colorDelta(img1, img2, k, m, yOnly) { let r1 = img1[k + 0]; let g1 = img1[k + 1]; let b1 = img1[k + 2]; let a1 = img1[k + 3]; let r2 = img2[m + 0]; let g2 = img2[m + 1]; let b2 = img2[m + 2]; let a2 = img2[m + 3]; if (a1 === a2 && r1 === r2 && g1 === g2 && b1 === b2) return 0; if (a1 < 255) { a1 /= 255; r1 = blend(r1, a1); g1 = blend(g1, a1); b1 = blend(b1, a1); } if (a2 < 255) { a2 /= 255; r2 = blend(r2, a2); g2 = blend(g2, a2); b2 = blend(b2, a2); } const y = rgb2y(r1, g1, b1) - rgb2y(r2, g2, b2); if (yOnly) return y; // brightness difference only const i = rgb2i(r1, g1, b1) - rgb2i(r2, g2, b2); const q = rgb2q(r1, g1, b1) - rgb2q(r2, g2, b2); return 0.5053 * y * y + 0.299 * i * i + 0.1957 * q * q; } function rgb2y(r, g, b) { return r * 0.29889531 + g * 0.58662247 + b * 0.11448223; } function rgb2i(r, g, b) { return r * 0.59597799 - g * 0.27417610 - b * 0.32180189; } function rgb2q(r, g, b) { return r * 0.21147017 - g * 0.52261711 + b * 0.31114694; } // blend semi-transparent color with white function blend(c, a) { return 255 + (c - 255) * a; } function drawPixel(output, pos, r, g, b) { output[pos + 0] = r; output[pos + 1] = g; output[pos + 2] = b; output[pos + 3] = 255; } function drawGrayPixel(img, i, alpha, output) { const r = img[i + 0]; const g = img[i + 1]; const b = img[i + 2]; const val = blend(rgb2y(r, g, b), alpha * img[i + 3] / 255); drawPixel(output, i, val, val, val); } function toVisuallyEqual(util, customEqualityTesters) { return { compare(actual, expected) { var result = {}; if (!expected) { let actualCanvas = createCanvasFromImageData(actual); result.message = buildError("No expected image defined", actualCanvas); result.pass = false; return result; } let width = expected.width; let height = expected.height; let diffCanvas = getCanvas(width, height); let diffContext = diffCanvas.getContext("2d"); const diff = diffContext.createImageData(width, height); try { let differingPixels = pixelmatch_1(expected.data, actual.data, diff.data, width, height, { threshold: 0.2, diffMask: true, alpha: 1, includeAA: false }); diffContext.putImageData(diff, 0, 0); result.pass = differingPixels === 0; if (!result.pass) { let actualCanvas = createCanvasFromImageData(actual); let expectedCanvas = createCanvasFromImageData(expected); let totalPixels = width * height; let percentDifference = (differingPixels / totalPixels * 100).toFixed(2); let message = `Images have ${differingPixels}/${totalPixels} (${percentDifference}%) differing pixels`; result.message = buildError(message, actualCanvas, expectedCanvas, diffCanvas); } } catch (error) { let actualCanvas = createCanvasFromImageData(actual); let expectedCanvas = createCanvasFromImageData(expected); result.pass = false; result.message = buildError(error.message, actualCanvas, expectedCanvas); } return result; } }; } /** * Jasmine custom equality tester to see if two ImageData objects are the same. * @see https://developer.mozilla.org/en-US/docs/Web/API/ImageData * * @param {ImageData} first * @param {ImageData} second */ function imageDataEquality(first, second) { if (first instanceof ImageData && second instanceof ImageData) { return first.width === second.width && first.height === second.height && pixelmatch_1(first.data, second.data, null, first.width, first.height) === 0; } } /** * @param {HTMLImageElement} img * @returns {ImageData} */ function imgToImageData(img) { var canvas = document.createElement("canvas"); var context = canvas.getContext("2d"); canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; context.drawImage(img, 0, 0); return context.getImageData(0, 0, canvas.width, canvas.height); } /** * @param {String} url * @returns {Promise} */ function loadImage(url) { var img = new Image(); img.src = url; return new Promise(resolve => { img.onload = () => resolve(img); }); } function fetchImageData(url) { return loadImage(url).then(img => imgToImageData(img)); } beforeEach(function () { jasmine.addMatchers({ toVisuallyEqual }); jasmine.addCustomEqualityTester(imageDataEquality); }); exports.fetchImageData = fetchImageData; exports.imgToImageData = imgToImageData; exports.loadImage = loadImage; return exports; }({})); //# sourceMappingURL=jasmine-pixelmatch.js.map