3

I have this problem with variable fonts and was wondering if someone has an idea for a solution. I have built this poster-generator, using variable fonts, where you can manipulate the font-variation-settings on two axes. Here is a live example http://automat.markjulienhahn.de

Now I am trying to download the result via html2canvas. Unfortunately it seems like canvas-objects do not support variable fonts, so the canvas-object can only show one state of the font and the fontVariationSettings don't have any effect.

This is how I pull the canvas element:

<script src="html2canvas.min.js"></script>    
  
<script>
    
var app = new Vue({
  el: '#app',
  methods: {
    saveCanvas(){
            html2canvas(document.querySelector("#capture")).then(
                canvas => {
                document.body.appendChild(canvas);
                var image = canvas.toDataURL("image/png").replace("image/png",  "image/octet-stream");
                console.log(image);  
                window.location.href=image;    
            });  
    }    
  }
})

</script>

And this is how I manipulate the Variable Font.

function randomizeState() {
    randomWeight = Math.floor(Math.random(1,100) * 100);
    randomWidth = Math.floor(Math.random(1,100) * 100);
    document.getElementById("element").style.fontVariationSettings = "\"frst\" " + randomWeight + ", \"scnd\" " + randomWidth;
    document.getElementById("state1").innerHTML = randomWeight + " " + randomWidth;
}

I would appreciate any help!

1 Answers1

2

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>
Kaiido
  • 123,334
  • 13
  • 219
  • 285
  • Works quite well, thanks! There used to be a stroke around the font, that gets lost with this method. How can I add this to the font. I have tried adding it to the style-Variable but it wouldn't appear. Any Ideas? – Mark Julien Hahn Jan 12 '21 at 17:20
  • @MarkJulienHahn I'm not quite sure what "stroke" you are talking about... I now realize I absolutely don't take into account the CSS positioning of the elements, but I guess it's not about this... – Kaiido Jan 13 '21 at 02:08
  • Yes, the CSS positioning is fine. What I mean is a simple outline around the letters, that I can get with "-webkit-text-stroke: 1px black;" in the CSS. Unfortunately this does not work in the SVG. – Mark Julien Hahn Jan 13 '21 at 07:41
  • Oh... weird, that one actually works for me: https://jsfiddle.net/5yoexz4f/ – Kaiido Jan 13 '21 at 07:56
  • Seems to be a problem in Safari. In Chrome it works fine. – Mark Julien Hahn Jan 14 '21 at 09:44
  • Can you provide a simple solution to make the SVG downloadable as a jpg? – Mark Julien Hahn Jan 14 '21 at 09:45
  • To make it downloadable as jpeg just call canvas.toBlob(blob=> saveBlob(blob), "image/jpeg") but for the kind of image you make png os actually better. It will have better quality (lossless) and since there isn't many colors it will even have better compression. And I'll check tomorrow for Safari – Kaiido Jan 14 '21 at 14:32
  • Thanks, now it is downloadable in Chrome but Safari also seems to have problems with that. I get a "Security Issue" error. Is there a fix for that? One more question: How can I change the size of the downloaded image? The width is set to 724px, which seems to be a default value? – Mark Julien Hahn Jan 14 '21 at 17:35
  • So actually the last edit did fix it for Safari rendering `text-stroke`: https://jsfiddle.net/Lk7ymbqx/ Regarding exporting to bitmap from this browser, no, there is no workaround. They still are tainting the canvas when a foreignObject has been painted on it... Sorry, since this is the only way to render variable fonts in a canvas, you won't be able to export your images in this browser... And for the size of the output image, see [this fiddle](https://jsfiddle.net/w6zajmy1/). – Kaiido Jan 15 '21 at 02:27