Fork me on GitHub

Source: MemePlayer.js

'use strict';

var GIF = require('readwrite-gif'),
  btoa = require('isomorphic-base64').btoa;

function isGIF(url) {
  return /\.gif$/i.test(url);
}

function isDefined(value) {
  return typeof value !== 'undefined';
}

function isString(value) {
  return typeof value === 'string';
}

var BASE64_DATAURI_REGEX = /^data:(\w+\/\w+);base64,(.*)$/;

function dataURLtoBase64(dataUrl) {
  var match = dataUrl.match(BASE64_DATAURI_REGEX);

  if (!match) {
    throw new Error("[MemePlayer:nodatauri] Can't match url: " + dataUrl +
      " to regex: " + BASE64_DATAURI_REGEX);
  }
  return match[2];
}

function getHttpAsArrayBuffer(url) {
  return fetch(url).then(function (response) {
    if (response.status >= 400) {
      return Promise.reject(
        new Error('[MemePlayer:http] HTTP request returned ' + response.status));
    }
    return response.arrayBuffer();
  });
}

function createImageInstance(canvasInstance) {
  // Hack: We want to use a native browser Image if we are in a browser.
  //       But if we are using node-canvas, we need to use it's Image
  //       implementation, ideally without require'ing it because that
  //       would make working with browserify difficult.
  if (typeof Image !== 'undefined') {
    // Browsers.
    return new Image();
  } else {
    // node-canvas.
    return new canvasInstance.constructor.Image();
  }
}

function createFrames(player, url) {
  var img, promise;

  if (isGIF(url)) {
    return getHttpAsArrayBuffer(url).then(function (data) {
      var decoder = new GIF.Decoder(new Uint8Array(data)),
        gif = { frames: [] },
        nFrames = decoder.numFrames(),
        imageData,
        lastImageRawData,
        frameInfo;

      player.$$ctx.fillStyle = "white";
      player.$$ctx.fillRect(0, 0, player.$$width, player.$$height);
      if (nFrames > 0) {
        imageData = player.$$ctx.createImageData(decoder.width, decoder.height);
        decoder.decodeAndBlitFrameRGBA(0, imageData.data);
        frameInfo = decoder.frameInfo(0);
        gif.frames.push({
          delay: frameInfo.delay,
          frame: imageData
        });
        lastImageRawData = imageData.data;
      }
      for (var i = 1; i < nFrames; i++) {
        imageData = player.$$ctx.createImageData(decoder.width, decoder.height);
        for (var j = 0; j < imageData.data.length; j++) {
          imageData.data[j] = lastImageRawData[j];
        }
        decoder.decodeAndBlitFrameRGBA(i, imageData.data);
        frameInfo = decoder.frameInfo(i);
        gif.frames.push({
          delay: frameInfo.delay,
          frame: imageData
        });
        lastImageRawData = imageData.data;
      }
      return gif;
    });
  } else {
    img = createImageInstance(player.$$canvas);
    img.crossOrigin = 'anonymous';
    promise = new Promise(function (resolve) {
      img.addEventListener('load', function () {
        var imageData;

        player.$$ctx.fillStyle = "white";
        player.$$ctx.fillRect(0, 0, player.$$width, player.$$height);
        player.$$ctx.drawImage(img, 0, 0);
        imageData = player.$$ctx.getImageData(0, 0, player.$$width, player.$$height);
        resolve({
          frames: [{ frame: imageData }]
        });
      });
    });
    img.src = url;
    return promise;
  }
}

function bestSplit (ctx, value) {
  var words = value.split(/\s+/),
      i,
      metrics,
      upperWidth,
      lowerWidth,
      renderWidth,
      minWidth,
      upperText,
      lowerText,
      bestValue;

  metrics = ctx.measureText(value);
  minWidth = metrics.width;
  bestValue = value;

  for (i = 1; i < words.length; i++) {
    upperText = words.slice(0, i).join(' ');
    metrics = ctx.measureText(upperText);
    upperWidth = metrics.width;
    lowerText = words.slice(i).join(' ');
    metrics = ctx.measureText(lowerText);
    lowerWidth = metrics.width;
    renderWidth = Math.max(upperWidth, lowerWidth);
    if (renderWidth < minWidth) {
      minWidth = renderWidth;
      bestValue = [upperText, lowerText];
    }
  }

  return {
    width: minWidth,
    value: bestValue
  };
}

function renderText (ctx, text, img) {
  var renderLine, xPos, yPos, upperOffset, lowerOffset;

  xPos = {
    left: 0,
    center: img.width/2,
    right: img.width
  };

  yPos = {
    top: 0,
    middle: img.height/2,
    bottom: img.height
  };

  upperOffset = {
    top: 0,
    middle: -30,
    bottom: -60
  };

  lowerOffset = {
    top: 60,
    middle: 30,
    bottom: 0
  };

  renderLine = function (value, align, baseline) {
    var metrics, scale, opt;

    ctx.textAlign = align;
    ctx.textBaseline = baseline;
    metrics = ctx.measureText(value);
    scale = Math.min(1.0, img.width / metrics.width);
    if (scale <= 0.5) {
      opt = bestSplit(ctx, value);
      scale = Math.min(1.0, img.width / opt.width);
      value = opt.value;
    }

    ctx.save();
    ctx.translate(xPos[align], yPos[baseline]);
    ctx.scale(scale, scale);
    if (isString(value)) {
      ctx.fillText(value, 0, 0);
      ctx.strokeText(value, 0, 0);
    } else {
      ctx.save();
      ctx.translate(0, upperOffset[baseline]);
      ctx.fillText(value[0], 0, 0);
      ctx.strokeText(value[0], 0, 0);
      ctx.restore();
      ctx.translate(0, lowerOffset[baseline]);
      ctx.fillText(value[1], 0, 0);
      ctx.strokeText(value[1], 0, 0);
    }
    ctx.restore();
  };

  ctx.font = "60px Impact";
  ctx.fillStyle = "white";
  ctx.strokeStyle = "black";
  ctx.lineWidth = 2;

  renderLine(text.top.value, text.top.align, 'top');
  renderLine(text.middle.value, text.middle.align, 'middle');
  renderLine(text.bottom.value, text.bottom.align, 'bottom');
}

function createContentData(canvas, ctx, text, image) {
  var encoder,
    frame,
    frameIndex;

  if (image.frames.length > 1) {
    // animated GIF
    frameIndex = 0;
    encoder = new GIF.Encoder();
    encoder.setRepeat(0);
    encoder.start();
    return new Promise(function (resolve) {
      var renderNextFrame = function () {
        frame = image.frames[frameIndex++];
        encoder.setDelay(frame.delay * 10); // 1/100ths sec -> ms
        ctx.fillStyle = "white";
        ctx.fillRect(0, 0, frame.frame.width, frame.frame.height);
        ctx.putImageData(frame.frame, 0, 0);
        renderText(ctx, text, frame.frame);
        encoder.addFrame(ctx);

        if (frameIndex === image.frames.length) {
          encoder.finish();
          resolve({
            data: btoa(encoder.stream().getData()),
            contentType: 'image/gif'
          });
        } else {
          // perf: don't bog down the event loop.
          setTimeout(renderNextFrame, 0);
        }
      };

      renderNextFrame();
    });
  } else {
    // JPG
    frame = image.frames[0];
    ctx.fillStyle = "white";
    ctx.fillRect(0, 0, frame.frame.width, frame.frame.height);
    ctx.putImageData(frame.frame, 0, 0);
    renderText(ctx, text, frame.frame);
    return Promise.resolve({
      data: dataURLtoBase64(canvas.toDataURL('image/jpeg')),
      contentType: 'image/jpeg'
    });
  }
}

/**
 * @class MemePlayer
 * @description
 * Enhances a canvas with meme viewing and editing capabilities.
 *
 * @param {!HTMLCanvasElement} canvas The canvas element to render into.
 * @param {!number} width The width of the canvas, in pixels.
 * @param {!number} height The height of the canvas, in pixels.
 */
function MemePlayer(canvas, width, height) {
  this.$$canvas = canvas;
  this.$$ctx = canvas.getContext('2d');
  this.$$image = null;
  this.$$text = {
    top: { value: '', align: 'center' },
    middle: { value: '', align: 'center' },
    bottom: { value: '', align: 'center' }
  };
  this.$$width = width;
  this.$$height = height;
  canvas.setAttribute('width', width);
  canvas.setAttribute('height', height);
}

/**
 * @method MemePlayer#setWidth
 * @description
 * Sets the width of the canvas.
 *
 * @param {number} width The new width of the canvas, in pixels.
 */
MemePlayer.prototype.setWidth = function (width) {
  this.$$width = width;
  this.$$canvas.setAttribute('width', width);
  this.$$redraw();
};

/**
 * @method MemePlayer#setHeight
 * @description
 * Sets the height of the canvas.
 *
 * @param {number} height The new height of the canvas, in pixels.
 */
MemePlayer.prototype.setHeight = function (height) {
  this.$$height = height;
  this.$$canvas.setAttribute('height', height);
  this.$$redraw();
};

/**
 * @method MemePlayer#loadTemplate
 * @description
 * Loads a template image and starts displaying it on the canvas.
 *
 * @param {!string} url The URL of the image to display. JPG, PNG and GIF are
 *    supported image formats.
 * @returns {Promise<Template>}
 */
MemePlayer.prototype.loadTemplate = function (url) {
  return createFrames(this, url).then(function (image) {
    this.$$image = image;
    this.$$frameIndex = 0;
    this.$$redraw();
    return image;
  }.bind(this));
};

/**
 * @private
 */
MemePlayer.prototype.$$redraw = function () {
  var ctx = this.$$ctx,
    image = this.$$image,
    text = this.$$text,
    timeout = this.$$timeout,
    frame;

  if (image) {
    frame = image.frames[this.$$frameIndex].frame;
    if (timeout) {
      clearTimeout(timeout);
      this.$$timeout = null;
    }
    ctx.fillStyle = 'white';
    ctx.fillRect(0, 0, frame.width, frame.height);
    ctx.putImageData(frame, 0, 0);
    renderText(ctx, text, frame);

    if (isDefined(image.frames[this.$$frameIndex].delay)) {
      this.$$timeout = setTimeout(function () {
        this.$$timeout = null;
        if (this.$$image) {
          this.$$frameIndex = (this.$$frameIndex + 1) % image.frames.length;
          this.$$redraw();
        }
      }.bind(this), image.frames[this.$$frameIndex].delay * 10);
    }
  }
};

/**
 * An object containing meme text to render over an image.
 * @typedef {Object} MemePlayer~MemeText
 * @property {Object} top An object representing the top line of text, if any.
 * @property {string} top.value The actual text content of the top line.
 * @property {string} top.align The alignment of the top line. Must be one of
 *    'left', 'right' or 'center'.
 * @property {Object} middle An object representing the middle line of text, if any.
 * @property {string} middle.value The actual text content of the middle line.
 * @property {string} middle.align The alignment of the middle line. Must be one of
 *    'left', 'right' or 'center'.
 * @property {Object} bottom An object representing the bottom line of text, if any.
 * @property {string} bottom.value The actual text content of the bottom line.
 * @property {string} bottom.align The alignment of the bottom line. Must be one of
 *    'left', 'right' or 'center'.
 */

/**
 * @method MemePlayer#setText
 * @description
 * Sets the meme text and re-renders the meme.
 *
 * @param {MemeText} text The new meme text to render.
 */
MemePlayer.prototype.setText = function (text) {
  this.$$text.top.value = text.top.value || '';
  this.$$text.top.align = text.top.align || 'center';
  this.$$text.middle.value = text.middle.value || '';
  this.$$text.middle.align = text.middle.align || 'center';
  this.$$text.bottom.value = text.bottom.value || '';
  this.$$text.bottom.align = text.bottom.align || 'center';
  this.$$redraw();
};

/**
 * @typedef {Object} MemePlayer~MemeData
 * @property {string} data The base64 encoded meme data.
 * @property {string} contentType The MIME type of the base64 encoded data.
 *    Guaranteed to be an image type, and if the loaded template was an animated
 *    GIF with multiple frames it's also guaranteed to be 'image/gif'.
 */

/**
 * @method MemePlayer#export
 * @description
 * Exports the content of the player to a data URL.
 *
 * @returns {Promise<MemeData>}
 */
MemePlayer.prototype.export = function () {
  return createContentData(this.$$canvas, this.$$ctx, this.$$text, this.$$image);
};

module.exports = MemePlayer;