Unfortunately you are right, we can't at the moment use variable-fonts in a canvas directly. So this makes the canvas renderer of html2canvas unable to render that correctly.
New versions of html2canvas come with a foreignObjectRenderer
, which uses the ability of the canvas API to draw SVG images, combined with the ability of SVG to contain HTML elements in a <foreignObject>
.
This is indeed the only current solution we have to draw variable-fonts on a canvas, however for this to work the font needs to be embedded inside the svg document that will be drawn on the canvas. And this, html2canvas doesn't do it for us (and even though I didn't checked recently I don't think other solutions like DOM2image does that either).
So we'll have to do it ourselves.
- First we need to fetch the font file (woff2) and encode it to a
data://
URL so it can live in a standalone svg file.
- Then we'll build the
<foreignObject>
element with a copy of our elements and their required computed styles.
- Finally we'll build the svg image with the
<foreignObject>
and a <style>
declaring our font-face from the data://
URL, and draw that on the canvas.
(async () => {
const svgNS = "http://www.w3.org/2000/svg";
const svg = document.createElementNS( svgNS, "svg" );
const font_data = await fetchAsDataURL( "https://fonts.gstatic.com/s/inter/v2/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa1ZL7W0Q5nw.woff2" );
const style = document.createElementNS( svgNS, "style" );
style.textContent = `@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 200 900;
src: url(${ font_data }) format('woff2');
}`;
svg.append( style );
const foreignObject = document.createElementNS( svgNS, "foreignObject" );
foreignObject.setAttribute( "x", 0 );
foreignObject.setAttribute( "y", 0 );
const target = document.querySelector( ".target" );
const clone = cloneWithStyles( target );
foreignObject.append( clone );
const { width, height } = target.getBoundingClientRect();
foreignObject.setAttribute( "width", width );
foreignObject.setAttribute( "height", height );
svg.setAttribute( "width", width );
svg.setAttribute( "height", height );
svg.append( foreignObject );
const svg_markup = new XMLSerializer().serializeToString( svg );
const svg_file = new Blob( [ svg_markup ], { type: "image/svg+xml" } );
const img = new Image();
img.src = URL.createObjectURL( svg_file );
await img.decode();
URL.revokeObjectURL( img.src );
const canvas = document.createElement( "canvas" );
Object.assign( canvas, { width, height } );
const ctx = canvas.getContext( "2d" );
ctx.drawImage( img, 0, 0 );
document.body.append( canvas );
})().catch( console.error );
function fetchAsDataURL( url ) {
return fetch( url )
.then( (resp) => resp.ok && resp.blob() )
.then( (blob) => new Promise( (res) => {
const reader = new FileReader();
reader.onload = (evt) => res( reader.result );
reader.readAsDataURL( blob );
} )
);
}
function cloneWithStyles( source ) {
const clone = source.cloneNode( true );
// to make the list of rules smaller we try to append the clone element in an iframe
const iframe = document.createElement( "iframe" );
document.body.append( iframe );
// if we are in a sandboxed context it may be null
if( iframe.contentDocument ) {
iframe.contentDocument.body.append( clone );
}
const source_walker = document.createTreeWalker( source, NodeFilter.SHOW_ELEMENT, null );
const clone_walker = document.createTreeWalker( clone, NodeFilter.SHOW_ELEMENT, null );
let source_element = source_walker.currentNode;
let clone_element = clone_walker.currentNode;
while ( source_element ) {
const source_styles = getComputedStyle( source_element );
const clone_styles = getComputedStyle( clone_element );
// we should be able to simply do [ ...source_styles.forEach( (key) => ...
// but thanks to https://crbug.com/1073573
// we have to filter all the snake keys from enumerable properties...
const keys = (() => {
// Start with a set to avoid duplicates
const props = new Set();
for( let prop in source_styles ) {
// Undo camel case
prop = prop.replace( /[A-Z]/g, (m) => "-" + m.toLowerCase() );
// Fix vendor prefix
prop = prop.replace( /^webkit-/, "-webkit-" );
props.add( prop );
}
return props;
})();
for( let key of keys ) {
if( clone_styles[ key ] !== source_styles[ key ] ) {
clone_element.style.setProperty( key, source_styles[ key ] );
}
}
source_element = source_walker.nextNode()
clone_element = clone_walker.nextNode()
}
// clean up
iframe.remove();
return clone;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 200 900;
src: url(https://fonts.gstatic.com/s/inter/v2/UcC73FwrK3iLTeHuS_fvQtMwCp50KnMa1ZL7W0Q5nw.woff2) format('woff2');
}
.t1 {
font-family: 'Inter';
font-variation-settings: 'wght' 200;
}
.t2 {
font-family: 'Inter';
font-variation-settings: 'wght' 900;
}
canvas {
border: 1px solid;
}
<div class="target">
<span class="t1">
Hello
</span>
<span class="t2">
World
</span>
</div>