'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;