This article will tell you how we managed to transform part of our app (mix of HTML & SVG elements) into an image.
We recently did an internal hackathon, where we all stopped working on our daily job to tackle fun and nice to have features that we would otherwise not prioritize. It was a great day of creativity and we had a lot of fun.
What we wanted and succeeded ;) to achieve was a feature allowing one of our user to capture an image of a chart, add some annotation, and send the annotated image by email. This feature can be useful if you want to share some noteworthy data with someone who is not necessarily authorized on the app, or if you’d like to draw a cat on Toucan Toco’s dataviz, whatever floats your boat.
The general idea is this: HTML&SVG » Canvas » Image. I’ve seen a lot of resources on the subject, but all of them addressed only parts of that pipeline, not the whole, and none worked in a satisfying way out of the box: When it worked, the styles would be wrong, fonts would be wrong, some SVG elements wouldn’t display etc…
Since we went through a lot of trials an errors to get a good result, I thought I’d spare you the trouble.
What you want is to put your HTML and SVG elements into a canvas. For this to look good, you will need to inline all computed styles. Because if you only copy HTML, styles coming from stylesheets will not be copied with it.
To quickly hack it we shamelessly copied the setInlineStyles
from SVG Crowbar This method will parse the DOM, getComputedStyle
on each element, and inline these styles.
gotcha number 1: We had to make a modification to the crowbar code: The SVG we are parsing is handled with d3.js, and elements like
rect
for instance already have a width and height set. As a result, we had to exclude some styles on SVG elements :width
,height
,min-width
andmin-height
. I’m not sure why, and whether it’s specific to d3.js. If you do know, leave a comment!
Note: In the SVG crowbar, the code was designed for SVG only, we used it on a div containing SVG and it worked fine.
// Set Inline Styles method
function setInlineStyles(svg) {
function explicitlySetStyle (element) {
var cSSStyleDeclarationComputed = getComputedStyle(element);
var i, len, key, value;
var svgExcludedValues = ['height', 'width', 'min-height', 'min-width'];
var computedStyleStr = "";
for (i=0, len=cSSStyleDeclarationComputed.length; i<len; i++) {
key=cSSStyleDeclarationComputed[i];
if (!((element instanceof SVGElement) && svgExcludedValues.indexOf(key) >= 0)) {
value=cSSStyleDeclarationComputed.getPropertyValue(key);
computedStyleStr+=key+":"+value+";";
}
}
element.setAttribute('style', computedStyleStr);
}
function traverse(obj){
var tree = [];
tree.push(obj);
visit(obj);
function visit(node) {
if (node && node.hasChildNodes()) {
var child = node.firstChild;
while (child) {
if (child.nodeType === 1 && child.nodeName != 'SCRIPT'){
tree.push(child);
visit(child);
}
child = child.nextSibling;
}
}
}
return tree;
}
// hardcode computed css styles inside SVG
var allElements = traverse(svg);
var i = allElements.length;
while (i--){
explicitlySetStyle(allElements[i]);
}
}
// Use the setInlineStyles method
var elementToExport = document.querySelector('#stuff-container');
setInlineStyles(elementToExport);
Now that we have properly copied our styles, so let’s draw that onto a canvas.
The rest is pretty simple: you copy the innerHTML
and draw it on a HTML canvas. I used rasterizehtml.
var rasterize = require('rasterizehtml');
// More on the font face below
var fontFaceRule = "<style>\n@font-face {\n font-family: \"Montserrat\";\n src: url('../fonts/Montserrat-Regular.ttf') format('truetype');\n}\n</style>";
// Get the html with inlined styles
var htmlContent = fontFaceRule + elementToExport.innerHTML;
// Create a canvas element
var canvas = document.createElement('canvas');
canvas.setAttribute('id', 'canvas');
// Set the width and height of the canvas to match the element's
canvas.width = elementToExport.getBoundingClientRect().width;
canvas.height = elementToExport.getBoundingClientRect().height;
// Append the canvas to your page, this does not have to be done on the body
document.body.appendChild(canvas);
// Draw the HTML
rasterize.drawHTML(htmlContent, canvas);
You can see we manually added a @font-face
rule. This is due to another gotcha: if your font is not a standard font, you will need to add this rule.
You can probably try and detect the font face from your stylesheet, then find the font-family used and add the rules accordingly (Some SO resources here and here – I have not tried this). In my case I have only one font, and I know which, so I just added it.
Note 1: I get an error in the console (chrome) that does not seem to affect the end result:
Blocked script execution in 'about:blank' because the document's frame is sandboxed and the 'allow-scripts' permission is not set.
Note 2: A nice bonus is the fact that we use d3.js zoom to handle scrolling, and thanks to this method, the chart will be captured at the right scroll level, since it’s all transforms :)
I’m not gonna detail here how to make a Paint like feature using canvas, there’s plenty of resources out there.
This is very very simple.
// Get the Base64 image
var imageURL = canvas.toDataURL("image/png");
// Assuming you have a #png-container div somewhere
document.querySelector('#png-container').innerHTML = '<img src="'+imageURL+'"/>';
// Or you might want to download the image
// I did not use this method, so you might want to refine it a bit
window.location.href = imageURL.replace("image/png", "image/octet-stream");
In my case, I wanted the user to be able to send an email to someone with the image attached. This is sadly not possible with a mailto
, so the dataurl was sent to our back-end for the rest of the feature.
Note: The image will have a transparent background. You might need to set the background color on your element before exporting if this is an issue.
The chart is in SVG, done using d3.js, the grey block to the right is HTML. I hope you appreciate the quality of my doodle. The original chart
the canvas+doodle version
We’ve succeeded in getting a doodled capture of a part of our app! Hope this was useful to you.
I would love some feedbacks, so please do comment ! ;)
Sophie Despeisse, dev @ Toucan Toco