"use strict"; /** * Creates new instance of Image. * * @class * @name Image * @arg {string} srcPath - path to processed image * @arg {object} [srcOpts] - image options * @arg {number} [srcOpts.scaleX=1] - image current scale value on `X` axis * @arg {number} [srcOpts.scaleY=1] - image current scale value on `Y` axis * @arg {object} [injects] - Dependency injections. * @arg {object} [injects.sharp] - Injected `sharp` module. */ var fs = require("fs"); var _ = require("lodash"); var pixelmatch = require("pixelmatch"); var PNG = require("pngjs").PNG; var sharp = require("sharp"); var temp = require("temp").track(); var U = require("glace-utils"); sharp.cache(false); var Image = function (srcPath, srcOpts, injects) { if (!(this instanceof Image)) return new Image(srcPath, srcOpts); srcOpts = U.defVal(srcOpts, {}); srcOpts.scaleX = U.defVal(srcOpts.scaleX, 1); srcOpts.scaleY = U.defVal(srcOpts.scaleY, 1); this._srcPath = srcPath; this._srcOpts = srcOpts; this._pixelDenominator = 255 * Math.sqrt(3); injects = U.defVal(injects, {}); this.__sharp = U.defVal(injects.sharp, sharp); }; /** * Defines whether processed image is transparent. * * @async * @method * @return {Promise<boolean>} - `true` if image is transparent, `false` * otherwise. */ Image.prototype.isTransparent = function () { return this._loadImg(this._srcPath, this._srcOpts).then(image => { var pixels = this._getPixels(image); var height = pixels.length; var width = pixels[0].length; for (var y = 0; y < height; y++) { var row = pixels[y]; for (var x = 0; x < width; x++) { if (row[x].A !== 0) return false; }; }; return true; }); }; /** * Defines whether processed image is monochrome. * * @async * @method * @return {Promise<boolean>} - `true` if image is monochrome, `false` * otherwise. */ Image.prototype.isMonochrome = function () { return this._loadImg(this._srcPath, this._srcOpts).then(image => { var pixels = this._getPixels(image); var height = pixels.length; var width = pixels[0].length; var firstPixel = pixels[0][0]; for (var y = 0; y < height; y++) { var row = pixels[y]; for (var x = 0; x < width; x++) { if (row[x].A !== firstPixel.A || row[x].R !== firstPixel.R || row[x].G !== firstPixel.G || row[x].B !== firstPixel.B) { return false; }; }; }; return true; }); }; /** * Defines whether processed image is black. * * @async * @method * @return {Promise<boolean>} - `true` if image is black, `false` otherwise. */ Image.prototype.isBlack = function () { return this._loadImg(this._srcPath, this._srcOpts).then(image => { var pixels = this._getPixels(image); var height = pixels.length; var width = pixels[0].length; for (var y = 0; y < height; y++) { var row = pixels[y]; for (var x = 0; x < width; x++) { if (row[x].A !== 255 || row[x].R !== 0 || row[x].G !== 0 || row[x].B !== 0) { return false; }; }; }; return true; }); }; /** * Defines whether processed image is white. * * @async * @method * @return {Promise<boolean>} - `true` if image is white, `false` otherwise. */ Image.prototype.isWhite = function () { return this._loadImg(this._srcPath, this._srcOpts).then(image => { var pixels = this._getPixels(image); var height = pixels.length; var width = pixels[0].length; for (var y = 0; y < height; y++) { var row = pixels[y]; for (var x = 0; x < width; x++) { if (row[x].A !== 255 || row[x].R !== 255 || row[x].G !== 255 || row[x].B !== 255) { return false; }; }; }; return true; }); }; /** * Defines if processed image includes specified image. * * @method * @arg {string} dstPath - path to potentially included image * @arg {object} [dstOpts] - included image options * @arg {number} [dstOpts.tolerance=0.05] - comparison tolerance * @arg {?string} [dstOpts.matchedPath=null] - path to same captured place * @arg {boolean} [dstOpts.saveMatch=false] - flag to capture images * intersection or no * @return {Promise<object>} - result */ Image.prototype.includes = function (dstPath, dstOpts) { dstOpts = U.defVal(dstOpts, {}); dstOpts.scaleX = U.defVal(dstOpts.scaleX, 1); dstOpts.scaleY = U.defVal(dstOpts.scaleY, 1); this._dstPath = dstPath; this._dstOpts = dstOpts; this._tolerance = U.defVal(dstOpts.tolerance, .05); this._matchedPath = U.defVal(dstOpts.matchedPath); this._saveMatch = U.defVal(dstOpts.saveMatch, !!this._matchedPath); if (this._saveMatch && !this._matchedPath) this._matchedPath = temp.path({ suffix: ".png" }); var srcImage; return this._loadImg(this._srcPath, this._srcOpts).then(image => { srcImage = image; return this._loadImg(this._dstPath, this._dstOpts); }).then(dstImage => { return this._includes(srcImage, dstImage); }).then(result => { if (!result.isIncluded) return { isIncluded: false }; if (!this._saveMatch) return result; return this ._saveMatchImage(result.offsetX, result.offsetY, result.width, result.height) .then(() => { result.matchedPath = this._matchedPath; return result; }); }); }; /** * Defines if processed image equal to specified image. * * @method * @arg {string} dstPath - path to potentially equal image * @arg {object} [dstOpts] - included image options * @arg {number} [dstOpts.tolerance=0.05] - comparison tolerance * @arg {?string} [dstOpts.diffPath=null] - path to save captured difference. * @arg {boolean} [dstOpts.saveDiff=false] - flag to capture images * intersection or no * @return {Promise<number>} Ratio of difference. */ Image.prototype.equalTo = function (dstPath, dstOpts) { dstOpts = U.defVal(dstOpts, {}); dstOpts.scaleX = U.defVal(dstOpts.scaleX, 1); dstOpts.scaleY = U.defVal(dstOpts.scaleY, 1); var tolerance = U.defVal(dstOpts.tolerance, .05); var diffPath = U.defVal(dstOpts.diffPath); var saveDiff = U.defVal(dstOpts.saveDiff, !!diffPath); if (saveDiff && !diffPath) { diffPath = temp.path({ suffix: ".png" }); } var srcImage; return this._loadImg(this._srcPath, this._srcOpts).then(image => { srcImage = image; return this._loadImg(dstPath, dstOpts); }).then(dstImage => { var diffImage, diffData = null; if (diffPath) { diffImage = new PNG({ width: srcImage.width, height: srcImage.height }); diffData = diffImage.data; }; var diffPixels = pixelmatch( srcImage.data, dstImage.data, diffData, srcImage.width, srcImage.height, { threshold: tolerance }); var diff = _.round(diffPixels / (srcImage.width * srcImage.height), 2); if (diffImage) { return new Promise((resolve, reject) => { var stream = fs.createWriteStream(diffPath); stream.on("finish", () => stream.close(() => resolve(diff))); stream.on("error", reject); diffImage.pack().pipe(stream); }); } else { return diff; } }); }; /** * Loads image. * * @async * @method * @protected * @arg {string} imgPath - path loaded image * @arg {object} imgOpts - image options * @return {Promise<object>} - image data */ Image.prototype._loadImg = function(imgPath, imgOpts) { var width, height, hasAlpha, channels; var img = this.__sharp(imgPath); return img.metadata().then(metadata => { hasAlpha = metadata.hasAlpha; channels = metadata.channels; width = Math.ceil(metadata.width / imgOpts.scaleX); height = Math.ceil(metadata.height / imgOpts.scaleY); return img.resize(width, height).raw().toBuffer(); }).then(data => { return { width: width, height: height, data: data, channels: channels, hasAlpha: hasAlpha }; }); }; /** * Saves matched part of image. * * @async * @method * @protected * @arg {number} left - offset from left image border * @arg {number} top - offset from top image border * @arg {number} width - width of matched part * @arg {number} height - height of matched part * @return {Promise<void>} */ Image.prototype._saveMatchImage = function (left, top, width, height) { return this.__sharp(this._srcPath) .extract({ left: left, top: top, width: width, height: height }) .toFile(this._matchedPath); }; /** * Defines whether one image data includes another. * * @method * @protected * @arg {object} src - source image data * @arg {object} dst - destination image data, which is potentially included * @return {object} - result */ Image.prototype._includes = function (src, dst) { var srcPixels = this._cropPixels(this._getPixels(src)), dstPixels = this._cropPixels(this._getPixels(dst)); var offsetX, offsetY, diffValue; var srcWidth = srcPixels[0].length, srcHeight = srcPixels.length, dstWidth = dstPixels[0].length, dstHeight = dstPixels.length; var result = { isIncluded: false, diffValue: null, offsetX: null, offsetY: null, width: dstWidth, height: dstHeight }; var deltaX = srcWidth - dstWidth, deltaY = srcHeight - dstHeight; var dstUsedPixels = this._getUsedPixels(dstPixels); for (offsetY = 0; offsetY <= deltaY; offsetY++) { for (offsetX = 0; offsetX <= deltaX; offsetX++) { diffValue = this._getDiffValue(srcPixels, dstUsedPixels, offsetX, offsetY); if (diffValue === null) continue; if (result.diffValue === null || result.diffValue > diffValue) { result.isIncluded = true; result.diffValue = diffValue; result.offsetX = offsetX; result.offsetY = offsetY; }; if (diffValue === 0 || !this._saveMatch) return result; }; }; return result; }; /** * Gets used pixels. * * @method * @protected * @arg {object[][]} pixels - pixels matrix * @return {object[]} List of used pixels. */ Image.prototype._getUsedPixels = function (pixels) { var usedPixels = [], width = pixels[0].length, height = pixels.length, x, y, row; for (y = 0; y < height; y++) { row = pixels[y]; for (x = 0; x < width; x++) { if (this._isPixelUsed(row[x])) { usedPixels.push({ pixel: row[x], x: x, y: y }); }; }; }; return usedPixels; }; /** * Calculates difference between source pixels and destination pixels. * * @method * @protected * @arg {object[][]} srcPixels - source pixels matrix * @arg {object[][]} dstPixels - destination pixels matrix * @arg {number} offsetX - source left border offset * @arg {number} offsetY - source top border offset * @return {number} - difference value */ Image.prototype._getDiffValue = function (srcPixels, dstPixels, offsetX, offsetY) { var diffPixels = 0, limit = this._tolerance * dstPixels.length, dstPixel, srcPixel, i; for (i = 0; i < dstPixels.length; i++) { dstPixel = dstPixels[i]; srcPixel = srcPixels[offsetY + dstPixel.y][offsetX + dstPixel.x]; if (this._isPixelTolerant(srcPixel, dstPixel.pixel)) continue; diffPixels++; if (diffPixels > limit) return null; }; return diffPixels / dstPixels.length; }; /** * Defines whether pixel should be used for difference calculation or no, * according its alpha value. * * @method * @protected * @arg {object} pixel - pixel * @return {boolean} - `true` if should be, `false` otherwise */ Image.prototype._isPixelUsed = function (pixel) { return (255 - pixel.A) / 255 < this._tolerance; }; /** * Defines whether source pixel is tolerant to destination pixel. * * @method * @protected * @arg {object} srcPixel - source pixel * @arg {object} dstPixel - destination pixel * @return {boolean} - `true` if pixels are tolerant, `false` otherwise */ Image.prototype._isPixelTolerant = function(srcPixel, dstPixel) { var deltaR = srcPixel.R - dstPixel.R, deltaG = srcPixel.G - dstPixel.G, deltaB = srcPixel.B - dstPixel.B; return Math.sqrt(deltaR * deltaR + deltaG * deltaG + deltaB * deltaB) / this._pixelDenominator < this._tolerance; }; /** * Retrieves pixels from image data. * * @method * @protected * @arg {object} img - image data * @return {object[][]} - pixels matrix */ Image.prototype._getPixels = function (img) { var pixels = [], height = img.height, width = img.width * img.channels, x, y, offset; for (y = 0; y < height; y++) { offset = width * y; pixels.push([]); for (x = 0; x < width; x += img.channels) { pixels[y].push({ R: img.data[offset + x], G: img.data[offset + x + 1], B: img.data[offset + x + 2], A: img.hasAlpha ? img.data[offset + x + 3] : 255, }); }; }; return pixels; }; /** * Crops pixels, removes empty rows and columns. * * @method * @protected * @arg {object[][]} pixels - pixels matrix * @return {object[][]} - cropped pixels matrix */ Image.prototype._cropPixels = function (pixels) { pixels = this._cropTop(pixels); pixels.reverse(); pixels = this._cropTop(pixels); pixels.reverse(); pixels = this._transpose(pixels); pixels = this._cropTop(pixels); pixels.reverse(); pixels = this._cropTop(pixels); pixels.reverse(); return this._transpose(pixels); }; /** * Crops top part of pixels matrix. * * @method * @protected * @arg {object[][]} pixels - pixels matrix * @return {object[][]} - cropped pixels matrix */ Image.prototype._cropTop = function (pixels) { var croppedPixels = [], isTransparent = true, width = pixels[0].length, height = pixels.length, x, y, pixelsRow; for (y = 0; y < height; y++) { pixelsRow = pixels[y]; if (isTransparent) { for (x = 0; x < width; x++) { if (pixelsRow[x].A !== 0) { isTransparent = false; break; }; }; }; if (!isTransparent) croppedPixels.push(pixelsRow); }; return croppedPixels; }; /** * Transposes pixels matrix. * * @method * @arg {object[][]} pixels - pixels matrix * @return {object[][]} transposed pixels matrix */ Image.prototype._transpose = function (pixels) { var width = pixels[0].length, height = pixels.length, tPixels = [], x, y; if (width === 0 || height === 0) return []; for (y = 0; y < width; y++) { tPixels[y] = []; for (x = 0; x < height; x++) { tPixels[y][x] = pixels[x][y]; }; }; return tPixels; }; module.exports = Image;