Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
Generate PNG from a React-powered SVG. Server-side.
/**
* Run this with `babel-node generateSVG.js`
*/
import fs from 'fs';
import path from 'path';
import { Readable } from 'stream';
import childProcess from 'child_process';
import phantomjs from 'phantomjs';
import im from 'imagemagick';
import tmp from 'tmp';
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import LineChart from '../js/components/chart/LineChart.js';
/**
* Generate raw SVG string with React and components from the client app.
* Note that we have to force "xmlns" attribute into the final SVG, otherwise
* PhantomJS will be unable to parse it correctly, resulting in broken chart
* @param {[type]} data JSON array of performance data
* @return {Buffer} SVG string converted to buffer
*/
function generateStaticMarkupSVG(data) {
const chart = ReactDOMServer.renderToStaticMarkup(
<LineChart data={ data } font="MuseoSans" />
);
const buffer = chart.replace('<svg ', '<svg xmlns="http://www.w3.org/2000/svg" ');
return new Buffer(buffer);
}
/**
* Removes the prefix from Base64 encoded image string
* @param {Buffer} buffer Buffer we get from PhantomJS
* @return {Buffer} Buffer without the prefix
*/
function removePrefix(buffer) {
const PREFIX = "data:image/png;base64,";
const result = buffer.toString();
return new Buffer(result.substring(PREFIX.length), "base64");
}
/**
* Launch PhantomJS script as a child process. This is how PhantomJS works.
* @param {Array} args Arguments to pass to the child process
* @return {Process} NodeJS process (with stdout and stdin)
*/
function launchPhantomJS(args) {
return childProcess.execFile(phantomjs.path, args, { maxBuffer: Infinity });
}
/**
* Send the buffer of our SVG chart to PhantomJS child process through stdin.
* @param {Buffer} sourceBuffer
* @param {Process} child
*/
function writeBufferToChild(sourceBuffer, child) {
child.stdin.setEncoding('utf-8');
child.stdin.write(sourceBuffer.toString("base64"));
child.stdin.end();
}
const ImageMagickArgs = [
'-filter', 'Triangle',
'-define', 'filter:support=2',
'-unsharp', '0.25x0.25+8+0.065',
'-dither', 'None',
'-posterize', '136',
'-define', 'png:compression-filter=1',
'-define', 'png:compression-level=9',
'-define', 'png:compression-strategy=4',
'-define', 'png:exclude-chunk=all',
'-interlace', 'none',
'-interpolate', 'integer',
'-colorspace', 'sRGB',
'-strip',
'-resize', '1200x600',
'-depth', '16',
'-quality','100',
];
/**
* Take the pre-set params for ImageMagick and generate two additional
* parameters: input file and output file, which should be positioned as the
* first and last elements of the configuration array, respectively.
* Also generate a temporary file to save the pre-resized image string to
* @param {Buffer} buffer Base64 encoded PNG string
* @param {String} partnerTrackingName Tracking name for file name generation
* @return {Array} ImageMagick params array
*/
function generateIMParams(buffer, partnerTrackingName) {
const tmpFile = tmp.fileSync();
fs.writeFileSync(tmpFile.name, buffer);
const epoch = Math.floor((new Date).getTime()/1000);
const finalFileName = `report-${partnerTrackingName}-${epoch}.png`;
const outputPath = path.resolve('reports', finalFileName);
ImageMagickArgs.unshift(tmpFile.name); // put the input file at the beginning
ImageMagickArgs.push(outputPath); // put the output file at the end
return ImageMagickArgs;
}
/**
* Change the following twi variables when implementing a call with Ruby
*/
import data from '../js/data.js';
const partnerTrackingName = 'johnCena';
const sourceBuffer = generateStaticMarkupSVG(data);
const phantomChild = launchPhantomJS([path.resolve(__dirname, 'render.js')]);
let base64EncodedImage = '';
phantomChild.stdout.on('data', (chunk) => base64EncodedImage += chunk );
/**
* When Phantom ends with generating the (Base64 encoded) image, we we remove
* the prefix (because it results in corrupt image), generate the params for
* ImageMagick, and run it to downsize the image
*/
phantomChild.stdout.on('end', function() {
const buffer = removePrefix(base64EncodedImage);
const args = generateIMParams(buffer, partnerTrackingName);
im.convert(args, err => {
if (err) throw err;
process.stdout.write(args.pop());
});
});
writeBufferToChild(sourceBuffer, phantomChild);
/**
* This file is called as a child process to make sure it's running in a
* PhantomJS-aware environment.
* ES6 can't be used here.
*/
const page = require('webpage').create();
const system = require('system');
const sourceBase64 = system.stdin.readLine();
/**
* Set some defaults. Since our SVG chart is 600x300 pixels, we create 4 times
* larger viewport, in order to make sure the resolution is good. We resize
* the image to smaller dimension in parent process.
*/
page.viewportSize = { width: 2400, height: 1200 };
page.clipRect = { top: 0, left: 0, width: 2400, height: 1200 };
page.zoomFactor = 4;
/**
* Opens a page with PhantomJS, and it has Base64-encoded string of the SVG
* image in the address bar, instead of a regular address, which is valid.
* Writes result to stdout, which parent's process is able to read and process.
*/
const PREFIX = "data:image/svg+xml;base64,";
page.open(PREFIX + sourceBase64, function() {
const result = "data:image/png;base64," + page.renderBase64("PNG");
system.stdout.write(result);
phantom.exit();
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment