index.js 9.37 KB
/* eslint-disable no-labels */

import { throwError, isNodePattern } from '@jimp/utils';

export default function pluginCrop(event) {
  /**
   * Crops the image at a given point to a give size
   * @param {number} x the x coordinate to crop form
   * @param {number} y the y coordinate to crop form
   * @param w the width of the crop region
   * @param h the height of the crop region
   * @param {function(Error, Jimp)} cb (optional) a callback for when complete
   * @returns {Jimp} this for chaining of methods
   */
  event('crop', function(x, y, w, h, cb) {
    if (typeof x !== 'number' || typeof y !== 'number')
      return throwError.call(this, 'x and y must be numbers', cb);
    if (typeof w !== 'number' || typeof h !== 'number')
      return throwError.call(this, 'w and h must be numbers', cb);

    // round input
    x = Math.round(x);
    y = Math.round(y);
    w = Math.round(w);
    h = Math.round(h);

    if (x === 0 && w === this.bitmap.width) {
      // shortcut
      const start = (w * y + x) << 2;
      const end = (start + h * w) << 2;

      this.bitmap.data = this.bitmap.data.slice(start, end);
    } else {
      const bitmap = Buffer.allocUnsafe(w * h * 4);
      let offset = 0;

      this.scanQuiet(x, y, w, h, function(x, y, idx) {
        const data = this.bitmap.data.readUInt32BE(idx, true);
        bitmap.writeUInt32BE(data, offset, true);
        offset += 4;
      });

      this.bitmap.data = bitmap;
    }

    this.bitmap.width = w;
    this.bitmap.height = h;

    if (isNodePattern(cb)) {
      cb.call(this, null, this);
    }

    return this;
  });

  return {
    class: {
      /**
       * Autocrop same color borders from this image
       * @param {number} tolerance (optional): a percent value of tolerance for pixels color difference (default: 0.0002%)
       * @param {boolean} cropOnlyFrames (optional): flag to crop only real frames: all 4 sides of the image must have some border (default: true)
       * @param {function(Error, Jimp)} cb (optional): a callback for when complete (default: no callback)
       * @returns {Jimp} this for chaining of methods
       */
      autocrop(...args) {
        const w = this.bitmap.width;
        const h = this.bitmap.height;
        const minPixelsPerSide = 1; // to avoid cropping completely the image, resulting in an invalid 0 sized image

        let cb; // callback
        let leaveBorder = 0; // Amount of pixels in border to leave
        let tolerance = 0.0002; // percent of color difference tolerance (default value)
        let cropOnlyFrames = true; // flag to force cropping only if the image has a real "frame"
        // i.e. all 4 sides have some border (default value)
        let cropSymmetric = false; // flag to force cropping top be symmetric.
        // i.e. north and south / east and west are cropped by the same value

        // parse arguments
        for (let a = 0, len = args.length; a < len; a++) {
          if (typeof args[a] === 'number') {
            // tolerance value passed
            tolerance = args[a];
          }

          if (typeof args[a] === 'boolean') {
            // cropOnlyFrames value passed
            cropOnlyFrames = args[a];
          }

          if (typeof args[a] === 'function') {
            // callback value passed
            cb = args[a];
          }

          if (typeof args[a] === 'object') {
            // config object passed
            const config = args[a];

            if (typeof config.tolerance !== 'undefined') {
              ({ tolerance } = config);
            }

            if (typeof config.cropOnlyFrames !== 'undefined') {
              ({ cropOnlyFrames } = config);
            }

            if (typeof config.cropSymmetric !== 'undefined') {
              ({ cropSymmetric } = config);
            }

            if (typeof config.leaveBorder !== 'undefined') {
              ({ leaveBorder } = config);
            }
          }
        }

        /**
         * All borders must be of the same color as the top left pixel, to be cropped.
         * It should be possible to crop borders each with a different color,
         * but since there are many ways for corners to intersect, it would
         * introduce unnecessary complexity to the algorithm.
         */

        // scan each side for same color borders
        let colorTarget = this.getPixelColor(0, 0); // top left pixel color is the target color
        const rgba1 = this.constructor.intToRGBA(colorTarget);

        // for north and east sides
        let northPixelsToCrop = 0;
        let eastPixelsToCrop = 0;
        let southPixelsToCrop = 0;
        let westPixelsToCrop = 0;

        // north side (scan rows from north to south)
        colorTarget = this.getPixelColor(0, 0);
        north: for (let y = 0; y < h - minPixelsPerSide; y++) {
          for (let x = 0; x < w; x++) {
            const colorXY = this.getPixelColor(x, y);
            const rgba2 = this.constructor.intToRGBA(colorXY);

            if (this.constructor.colorDiff(rgba1, rgba2) > tolerance) {
              // this pixel is too distant from the first one: abort this side scan
              break north;
            }
          }

          // this row contains all pixels with the same color: increment this side pixels to crop
          northPixelsToCrop++;
        }

        // east side (scan columns from east to west)
        colorTarget = this.getPixelColor(w, 0);
        east: for (let x = 0; x < w - minPixelsPerSide; x++) {
          for (let y = 0 + northPixelsToCrop; y < h; y++) {
            const colorXY = this.getPixelColor(x, y);
            const rgba2 = this.constructor.intToRGBA(colorXY);

            if (this.constructor.colorDiff(rgba1, rgba2) > tolerance) {
              // this pixel is too distant from the first one: abort this side scan
              break east;
            }
          }

          // this column contains all pixels with the same color: increment this side pixels to crop
          eastPixelsToCrop++;
        }

        // south side (scan rows from south to north)
        colorTarget = this.getPixelColor(0, h);
        south: for (
          let y = h - 1;
          y >= northPixelsToCrop + minPixelsPerSide;
          y--
        ) {
          for (let x = w - eastPixelsToCrop - 1; x >= 0; x--) {
            const colorXY = this.getPixelColor(x, y);
            const rgba2 = this.constructor.intToRGBA(colorXY);

            if (this.constructor.colorDiff(rgba1, rgba2) > tolerance) {
              // this pixel is too distant from the first one: abort this side scan
              break south;
            }
          }

          // this row contains all pixels with the same color: increment this side pixels to crop
          southPixelsToCrop++;
        }

        // west side (scan columns from west to east)
        colorTarget = this.getPixelColor(w, h);
        west: for (
          let x = w - 1;
          x >= 0 + eastPixelsToCrop + minPixelsPerSide;
          x--
        ) {
          for (let y = h - 1; y >= 0 + northPixelsToCrop; y--) {
            const colorXY = this.getPixelColor(x, y);
            const rgba2 = this.constructor.intToRGBA(colorXY);

            if (this.constructor.colorDiff(rgba1, rgba2) > tolerance) {
              // this pixel is too distant from the first one: abort this side scan
              break west;
            }
          }

          // this column contains all pixels with the same color: increment this side pixels to crop
          westPixelsToCrop++;
        }

        // decide if a crop is needed
        let doCrop = false;

        // apply leaveBorder
        westPixelsToCrop -= leaveBorder;
        eastPixelsToCrop -= leaveBorder;
        northPixelsToCrop -= leaveBorder;
        southPixelsToCrop -= leaveBorder;

        if (cropSymmetric) {
          const horizontal = Math.min(eastPixelsToCrop, westPixelsToCrop);
          const vertical = Math.min(northPixelsToCrop, southPixelsToCrop);
          westPixelsToCrop = horizontal;
          eastPixelsToCrop = horizontal;
          northPixelsToCrop = vertical;
          southPixelsToCrop = vertical;
        }

        // make sure that crops are >= 0
        westPixelsToCrop = westPixelsToCrop >= 0 ? westPixelsToCrop : 0;
        eastPixelsToCrop = eastPixelsToCrop >= 0 ? eastPixelsToCrop : 0;
        northPixelsToCrop = northPixelsToCrop >= 0 ? northPixelsToCrop : 0;
        southPixelsToCrop = southPixelsToCrop >= 0 ? southPixelsToCrop : 0;

        // safety checks
        const widthOfRemainingPixels =
          w - (westPixelsToCrop + eastPixelsToCrop);
        const heightOfRemainingPixels =
          h - (southPixelsToCrop + northPixelsToCrop);

        if (cropOnlyFrames) {
          // crop image if all sides should be cropped
          doCrop =
            eastPixelsToCrop !== 0 &&
            northPixelsToCrop !== 0 &&
            westPixelsToCrop !== 0 &&
            southPixelsToCrop !== 0;
        } else {
          // crop image if at least one side should be cropped
          doCrop =
            eastPixelsToCrop !== 0 ||
            northPixelsToCrop !== 0 ||
            westPixelsToCrop !== 0 ||
            southPixelsToCrop !== 0;
        }

        if (doCrop) {
          // do the real crop
          this.crop(
            eastPixelsToCrop,
            northPixelsToCrop,
            widthOfRemainingPixels,
            heightOfRemainingPixels
          );
        }

        if (isNodePattern(cb)) {
          cb.call(this, null, this);
        }

        return this;
      }
    }
  };
}