Source: imageProcessor.js

'use strict';

/**
 * Defines functions to manipulate images.
 *
 * @example
 * // Load module "imageProcessor"
 * var fsApi = require('@openveo/api').imageProcessor;
 *
 * @module imageProcessor
 */

var path = require('path');
var os = require('os');
var async = require('async');
var nanoid = require('nanoid').nanoid;
var gm = require('gm').subClass({
  imageMagick: true
});
var fileSystem = process.requireApi('lib/fileSystem.js');

/**
 * Generates a thumbnail from the given image.
 *
 * Destination directory is automatically created if it does not exist.
 *
 * @method generateThumbnail
 * @static
 * @param {String} imagePath The image absolute path
 * @param {String} thumbnailPath The thumbnail path
 * @param {Number} [width] The expected image width (in px)
 * @param {Number} [height] The expected image height (in px)
 * @param {Boolean} [crop] Crop the image if the new ratio differs from original one
 * @param {Number} [quality] Expected quality from 0 to 100 (default to 90 with 100 the best)
 * @param {callback} callback Function to call when its done
 */
module.exports.generateThumbnail = function(imagePath, thumbnailPath, width, height, crop, quality, callback) {
  var image = gm(imagePath);

  async.waterfall([

    // Create thumbnail directory if it does not exist
    function(callback) {
      fileSystem.mkdir(path.dirname(thumbnailPath), function(error) {
        callback(error);
      });
    },

    // Get original image size
    function(callback) {
      image.size(callback);
    },

    // Generate thumbnail
    function(size, callback) {
      var ratio = size.width / size.height;
      var cropPosition = {};
      var resizeWidth = width || Math.round(height * ratio);
      var resizeHeight = height || Math.round(width / ratio);

      if (crop && width && height) {
        if (ratio < width / height) {
          resizeHeight = Math.round(width / ratio);
          cropPosition = {x: 0, y: Math.round((resizeHeight - height) / 2)};
          crop = resizeHeight > height;
        } else {
          resizeWidth = Math.round(height * ratio);
          cropPosition = {x: Math.round((resizeWidth - width) / 2), y: 0};
          crop = resizeWidth > width;
        }
      }

      image
        .noProfile()
        .quality(quality)
        .resizeExact(resizeWidth, resizeHeight);

      if (crop)
        image.crop(width, height, cropPosition.x, cropPosition.y);

      image.write(thumbnailPath, callback);
    }

  ], function(error) {
    callback(error);
  });
};

/**
 * Creates an image from a list of images.
 *
 * Input images are aggregated horizontally or vertically to create the new image.
 *
 * @method aggregate
 * @static
 * @param {Array} imagesPaths The list of paths of the images to add to the final image
 * @param {String} destinationPath The final image path
 * @param {Number} width The width of input images inside the image (in px)
 * @param {Number} height The height of input images inside the image (in px)
 * @param {Boolean} [horizontally=true] true to aggregate images horizontally, false to aggregate them vertically
 * @param {Number} [quality=90] Expected quality from 0 to 100 (default to 90 with 100 the best)
 * @param {String} [temporaryDirectoryPath] Path to the temporary directory to use to store intermediate images. It will
 * be removed at the end of the operation. If not specified a directory is created in /tmp/
 * @return {module:imageProcessor~aggregateCallback} callback Function to call when its done
 */
module.exports.aggregate = function(imagesPaths, destinationPath, width, height, horizontally, quality,
  temporaryDirectoryPath, callback) {
  var self = this;
  var asyncFunctions = [];
  var thumbnailsPaths = [];
  var images = [];

  // Validate arguments
  quality = quality || 90;

  // Use a temporary directory to store thumbnails
  temporaryDirectoryPath = path.join(temporaryDirectoryPath || path.join(os.tmpdir()), nanoid());

  imagesPaths.forEach(function(imagePath) {
    asyncFunctions.push(function(callback) {
      var thumbnailPath = path.join(temporaryDirectoryPath, path.basename(imagePath));
      thumbnailsPaths.push({
        originalPath: imagePath,
        thumbnailPath: thumbnailPath
      });

      self.generateThumbnail(
        imagePath,
        thumbnailPath,
        width,
        height,
        true,
        100,
        callback
      );
    });
  });

  async.series([

    // Create destination path directory if it does not exist
    function(callback) {
      fileSystem.mkdir(path.dirname(destinationPath), function(error) {
        callback(error);
      });
    },

    // Generate thumbnails
    function(callback) {
      async.parallel(asyncFunctions, callback);
    },

    // Aggregate thumbnails
    function(callback) {
      var firstThumbnail;

      for (var i = 0; i < thumbnailsPaths.length; i++) {
        var thumbnailsPath = thumbnailsPaths[i].thumbnailPath;

        if (!firstThumbnail)
          firstThumbnail = gm(thumbnailsPath);
        else
          firstThumbnail.append(thumbnailsPath, horizontally);

        images.push({
          sprite: destinationPath,
          image: thumbnailsPaths[i].originalPath,
          x: horizontally ? width * i : 0,
          y: horizontally ? 0 : height * i
        });
      }
      firstThumbnail
        .quality(quality)
        .write(destinationPath, callback);
    }
  ], function(error, results) {
    fileSystem.rm(temporaryDirectoryPath, function(removeError) {
      if (error || removeError) return callback(error || removeError);

      callback(null, images);
    });
  });
};

/**
 * Generates a sprite from a list of images.
 *
 * If the number of images exceeds the maximum number of images (depending on totalColumns and maxRows), extra images
 * won't be in the sprite.
 *
 * @method generateSprite
 * @static
 * @param {Array} imagesPaths The list of images path to include in the sprite
 * @param {String} destinationPath The sprite path
 * @param {Number} width The width of images inside the sprite (in px)
 * @param {Number} height The height of images inside the sprite (in px)
 * @param {Number} [totalColumns=5] The number of images per line in the sprite
 * @param {Number} [maxRows=5] The maximum number of lines of images in the sprite
 * @param {Number} [quality=90] Expected quality from 0 to 100 (default to 90 with 100 the best)
 * @param {String} [temporaryDirectoryPath] Path to the temporary directory to use to store intermediate images. It will
 * be removed at the end of the operation. If not specified a directory is created in /tmp/
 * @return {module:imageProcessor~generateSpriteCallback} callback Function to call when its done
 */
module.exports.generateSprite = function(imagesPaths, destinationPath, width, height, totalColumns, maxRows, quality,
  temporaryDirectoryPath, callback) {
  var self = this;
  var linesPaths = [];
  var images = [];

  // Validate arguments
  totalColumns = totalColumns || 5;
  maxRows = maxRows || 5;
  quality = quality || 90;

  // Create a copy of the list of images to avoid modifying the original
  imagesPaths = imagesPaths.slice(0);

  // Use a temporary directory to store intermediate images
  temporaryDirectoryPath = path.join(temporaryDirectoryPath || path.join(os.tmpdir()), nanoid());

  // It is possible to have less than the expected number of columns if not enough images
  // The number of rows varies depending on the number of columns and the number of images
  var numberOfColumns = Math.min(imagesPaths.length, totalColumns);
  var numberOfRows = Math.ceil(imagesPaths.length / numberOfColumns);

  if (numberOfRows > maxRows) {

    // The number of images exceeds the possible number of images implicitly specified by the number of columns and rows
    // Ignore extra images
    numberOfRows = maxRows;
    imagesPaths = imagesPaths.slice(0, numberOfRows * numberOfColumns);

  }

  /**
   * Creates sprite lines by aggregating images.
   *
   * @ignore
   * @param {Array} linesImagesPaths The list of images paths to aggregate
   * @param {String} linePath The path of the image to generate
   * @param {Number} lineWidth The line width (in px)
   * @param {Number} lineHeight The line height (in px)
   * @param {Boolean} horizontally true to create an horizontal line, false to create a vertical line
   * @param {Number} lineQuality The line quality from 0 to 100 (default to 90 with 100 the best)
   * @return {Function} The async function of the operation
   */
  var createLine = function(linesImagesPaths, linePath, lineWidth, lineHeight, horizontally, lineQuality) {
    return function(callback) {
      self.aggregate(
        linesImagesPaths,
        linePath,
        lineWidth,
        lineHeight,
        horizontally,
        lineQuality,
        temporaryDirectoryPath,
        callback
      );
    };
  };

  async.series([

    // Create destination path directory if it does not exist
    function(callback) {
      fileSystem.mkdir(path.dirname(destinationPath), function(error) {
        callback(error);
      });
    },

    // Create temporary directory if it does not exist
    function(callback) {
      fileSystem.mkdir(temporaryDirectoryPath, function(error) {
        callback(error);
      });
    },

    // Complete the grid defined by numberOfColumns and numberOfRows using transparent images if needed
    function(callback) {
      if (imagesPaths.length >= numberOfColumns * numberOfRows) return callback();

      var transparentImagePath = path.join(temporaryDirectoryPath, 'transparent.png');
      gm(width, height, '#00000000').write(transparentImagePath, function(error) {

        // Add as many as needed transparent images to the list of images
        var totalMissingImages = numberOfColumns * numberOfRows - imagesPaths.length;

        for (var i = 0; i < totalMissingImages; i++)
          imagesPaths.push(transparentImagePath);

        callback(error);
      });
    },

    // Create sprite horizontal lines
    function(callback) {
      var asyncFunctions = [];

      for (var i = 0; i < numberOfRows; i++) {
        var rowsImagesPaths = imagesPaths.slice(i * numberOfColumns, i * numberOfColumns + numberOfColumns);
        var lineWidth = width;
        var lineHeight = height;
        var linePath = path.join(temporaryDirectoryPath, 'line-' + i);

        linesPaths.push(linePath);
        asyncFunctions.push(createLine(rowsImagesPaths, linePath, lineWidth, lineHeight, true, 100));
      }

      async.parallel(asyncFunctions, function(error, results) {
        if (error) return callback(error);

        results.forEach(function(line) {
          line.forEach(function(image) {
            if (image.image === path.join(temporaryDirectoryPath, 'transparent.png')) return;

            var spritePathChunks = path.parse(image.sprite).name.match(/-([0-9]+)$/);
            var lineIndex = (spritePathChunks && parseInt(spritePathChunks[1])) || 0;

            image.y = image.y + (lineIndex * height);
            image.sprite = destinationPath;
            images.push(image);
          });
        });

        callback();
      });
    },

    // Aggregate lines vertically
    function(callback) {
      createLine(linesPaths, destinationPath, width * numberOfColumns, height, false, quality)(callback);
    }

  ], function(error, results) {
    fileSystem.rm(temporaryDirectoryPath, function(removeError) {
      if (error || removeError) return callback(error || removeError);

      callback(null, images);
    });
  });
};

/**
 * Generates a sprite from a list of images without specifying images sizes nor grid size.
 *
 * Size of images aren't changed.
 * Images or simply packed by height with all images of the same height on the same line starting by images with higher
 * height.
 *
 * @method generateSpriteFreely
 * @static
 * @param {Array} imagesPaths The list of images path to include in the sprite
 * @param {String} destinationPath The sprite path
 * @param {Number} [quality=90] Expected quality from 0 to 100 (default to 90 with 100 the best)
 * @param {String} [temporaryDirectoryPath] Path to the temporary directory to use to store intermediate images. It will
 * be removed at the end of the operation. If not specified a directory is created in /tmp/
 * @return {module:imageProcessor~generateSpriteFreelyCallback} callback Function to call when its done
 */
module.exports.generateSpriteFreely = function(
  imagesPaths,
  destinationPath,
  quality,
  temporaryDirectoryPath,
  callback
) {
  var images = [];
  var lineSprites = [];
  var sprite;

  /**
   * Generates a single line sprite from a list of images either horizontally or vertically.
   *
   * Images or simply packed one after another.
   *
   * @ignore
   * @param {Array} images The list of images
   * @param {String} images[].path The original image path
   * @param {Object} images[].size The image size
   * @param {Number} images[].size.width The image width
   * @param {Number} images[].size.height The image height
   * @param {Boolean} horizontally true to pack images horizontally, false to pack them vertically
   * @param {String} outputPath The sprite path
   * @param {Number} quality Expected quality from 0 to 100 (default to 90 with 100 the best)
   * @return {module:imageProcessor~generateSpriteFreelyInternalCallback} callback Function to call when its done
   */
  var createLine = function(images, horizontally, outputPath, quality, createLineCallback) {
    var firstImage;
    var position = 0;

    for (var image of images) {
      image.x = horizontally ? position : 0;
      image.y = !horizontally ? position : 0;

      if (!firstImage) {
        firstImage = gm(image.path).background('#00000000').quality(quality);
      } else {
        firstImage.append(image.path, horizontally);
      }

      position += horizontally ? image.size.width : image.size.height;
    }

    firstImage.write(outputPath, function(error) {
      if (error) {
        return createLineCallback(error);
      }

      createLineCallback(null, {
        path: outputPath,
        images: images
      });
    });
  };

  async.series([

    // Create sprite directory if it does not exist
    function(callback) {
      fileSystem.mkdir(path.dirname(destinationPath), callback);
    },

    // Create temporary directory if it does not exist
    function(callback) {
      fileSystem.mkdir(temporaryDirectoryPath, callback);
    },

    // Get size of all images
    function(callback) {
      var getSizeFunctions = [];

      imagesPaths.forEach(function(imagePath) {
        getSizeFunctions.push(function(getSizeCallback) {
          var image = gm(imagePath);
          image.size(function(error, size) {
            if (error) {
              return getSizeCallback(error);
            }

            images.push({path: imagePath, size: size});
            getSizeCallback();
          });
        });
      });

      async.parallel(getSizeFunctions, callback);
    },

    // Create horizontal lines
    function(callback) {
      images.sort(function(image1, image2) {
        if (
          (image1.size.height > image2.size.height) ||
          (image1.size.height === image2.size.height && image1.path > image2.path)
        ) {
          return -1;
        } else {
          return 1;
        }
      });

      var createLineFunctions = [];
      var horizontalLineImages = [];
      var lastImageHeight;
      var getCreateLineFunction = function(lineImages) {
        return function(createLineCallback) {
          createLine(
            lineImages,
            true,
            path.join(temporaryDirectoryPath, `${nanoid()}.png`),
            100,
            createLineCallback
          );
        };
      };

      images.forEach(function(image, index) {
        if (image.size.height !== lastImageHeight) {
          if (horizontalLineImages.length) {
            createLineFunctions.push(getCreateLineFunction(horizontalLineImages));
            horizontalLineImages = [];
          }
        }
        horizontalLineImages.push(image);
        lastImageHeight = image.size.height;

      });

      if (horizontalLineImages.length) {
        createLineFunctions.push(getCreateLineFunction(horizontalLineImages));
      }

      async.parallel(createLineFunctions, function(error, results) {
        if (error) {
          return callback(error);
        }

        lineSprites = results;
        callback();
      });
    },

    // Create sprite
    function(callback) {
      createLine(
        lineSprites.map(function(lineSprite) {
          return {
            path: lineSprite.path,
            size: {
              height: lineSprite.images[0].size.height
            }
          };
        }),
        false,
        destinationPath,
        quality || 90,
        function(error, result) {
          if (error) {
            return callback(error);
          }

          var spriteImages = [];
          var yPosition = 0;

          lineSprites.forEach(function(lineSprite) {
            lineSprite.images.forEach(function(image) {
              image.y = yPosition;
              spriteImages.push(image);
            });

            yPosition += lineSprite.images[0].size.height;
          });

          sprite = {
            path: result.path,
            images: spriteImages
          };

          callback();
        }
      );
    }

  ], function(error) {

    // Remove temporary directory
    fileSystem.rm(temporaryDirectoryPath, function(removeError) {
      if (error || removeError) {
        return callback(error || removeError);
      }
      callback(null, sprite);
    });

  });
};

/**
 * Generates sprites from a list of images.
 *
 * If the number of images don't fit in the grid defined by totalColumns * maxRows, then several sprites will be
 * created.
 * Additional sprites are suffixed by a number.
 *
 * @method generateSprites
 * @static
 * @param {Array} imagesPaths The list of images paths to include in the sprites
 * @param {String} destinationPath The first sprite path, additional sprites are suffixed by a number
 * @param {Number} width The width of images inside the sprite (in px)
 * @param {Number} height The height of images inside the sprite (in px)
 * @param {Number} [totalColumns=5] The number of images per line in the sprite
 * @param {Number} [maxRows=5] The maximum number of lines of images in the sprite
 * @param {Number} [quality=90] Expected quality from 0 to 100 (default to 90 with 100 the best)
 * @param {String} [temporaryDirectoryPath] Path to the temporary directory to use to store intermediate images. It
 * will be removed at the end of the operation. If not specified a directory is created in /tmp/
 * @return {module:imageProcessor~generateSpritesCallback} callback Function to call when its done
 */
module.exports.generateSprites = function(imagesPaths, destinationPath, width, height, totalColumns, maxRows, quality,
  temporaryDirectoryPath, callback) {
  var self = this;
  var asyncFunctions = [];

  // Validate arguments
  totalColumns = totalColumns || 5;
  maxRows = maxRows || 5;
  temporaryDirectoryPath = path.join(temporaryDirectoryPath || path.join(os.tmpdir()), nanoid());

  // Find out how many sprites that have to be created
  var spriteMaxImages = totalColumns * maxRows;
  var totalSprites = Math.ceil(imagesPaths.length / spriteMaxImages);

  /**
   * Creates a sprite.
   *
   * @ignore
   * @param {Array} spriteImagesPaths The list of images to include in the sprite
   * @param {String} spriteDestinationPath The sprite path
   * @return {Function} The async function of the operation
   */
  var createSprite = function(spriteImagesPaths, spriteDestinationPath) {
    return function(callback) {
      self.generateSprite(
        spriteImagesPaths,
        spriteDestinationPath,
        width,
        height,
        totalColumns,
        maxRows,
        quality,
        temporaryDirectoryPath,
        callback
      );
    };
  };

  for (var i = 0; i < totalSprites; i++) {
    var spriteImagesPaths = imagesPaths.slice(i * spriteMaxImages, i * spriteMaxImages + spriteMaxImages);
    var spriteDestinationPath = destinationPath;

    if (i > 0) {
      var destinationPathChunks = path.parse(destinationPath);
      destinationPathChunks.base = destinationPathChunks.name + '-' + i + destinationPathChunks.ext;
      spriteDestinationPath = path.format(destinationPathChunks);
    }

    asyncFunctions.push(createSprite(spriteImagesPaths, spriteDestinationPath));
  }

  async.parallel(asyncFunctions, function(error, results) {
    fileSystem.rm(temporaryDirectoryPath, function(removeError) {
      if (error || removeError) return callback(error || removeError);

      var images = [];
      results.forEach(function(sprite) {
        images = images.concat(sprite);
      });

      callback(null, images);
    });
  });
};

/**
 * @callback module:imageProcessor~generateSpritesCallback
 * @param {(Error|null)} error The error if an error occurred, null otherwise
 * @param {Array} images The list of images
 * @param {String} images[].sprite The path of the sprite file containing the image (destinationPath)
 * @param {String} images[].image The path of the original image
 * @param {Number} images[].x The x coordinate of the image top left corner inside the sprite
 * @param {Number} images[].y The y coordinate of the image top left corner inside the sprite
 */

/**
 * @callback module:imageProcessor~generateSpriteCallback
 * @param {(Error|null)} error The error if an error occurred, null otherwise
 * @param {Array} images The list of images
 * @param {String} images[].sprite The path of the sprite file containing the image (destinationPath)
 * @param {String} images[].image The path of the original image
 * @param {Number} images[].x The x coordinate of the image top left corner inside the sprite
 * @param {Number} images[].y The y coordinate of the image top left corner inside the sprite
 */

/**
 * @callback module:imageProcessor~generateSpriteFreelyCallback
 * @param {(Error|null)} error The error if an error occurred, null otherwise
 * @param {Object} sprite The generated image
 * @param {String} sprite.path The path of the sprite file containing the images
 * @param {Array} sprite.images The list of images inside the sprite
 * @param {String} sprite.images[].path The path of the original image
 * @param {Number} sprite.images[].x The x coordinate of the image top left corner inside the sprite
 * @param {Number} sprite.images[].y The y coordinate of the image top left corner inside the sprite
 * @param {Object} sprite.images[].size The image size
 * @param {Number} sprite.images[].size.width The image width
 * @param {Number} sprite.images[].size.height The image height
 */

/**
 * @callback module:imageProcessor~generateSpriteFreelyInternalCallback
 * @param {(Error|null)} error The error if an error occurred, null otherwise
 * @param {Object} sprite The generated image
 * @param {String} sprite.path The path of the sprite file containing the images
 * @param {Array} sprite.images The list of images inside the sprite
 * @param {String} sprite.images[].path The path of the original image
 * @param {Number} sprite.images[].x The x coordinate of the image top left corner inside the sprite
 * @param {Number} sprite.images[].y The y coordinate of the image top left corner inside the sprite
 */

/**
 * @callback module:imageProcessor~aggregateCallback
 * @param {(Error|null)} error The error if an error occurred, null otherwise
 * @param {Array} images The list of images
 * @param {String} images[].sprite The path of the sprite file containing the image (destinationPath)
 * @param {String} images[].image The path of the original image
 * @param {Number} images[].x The x coordinate of the image top left corner inside the sprite
 * @param {Number} images[].y The y coordinate of the image top left corner inside the sprite
 */