Search code examples
javascriptcanvashtml5-canvashtml2canvasvariable-fonts

Variable-Fonts in html5-canvas


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!


Solution

  • 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>