Search code examples
javascriptfonts

How do I get a variable font axes with javascript?


I'm beginning work on an editor that allows you to play with variable fonts, however a very simple problem stumped me:

Variable fonts have variation axes which allow you modify the visual properties of the typeface. A simple one is weight, which allows you to go from light to black weight, for example.

The problem is that I don't know beforehand which variation axes are available in the font, so I can't dynamically display the correct sliders for the font.

Is there programmatic way in JavaScript to find the variation axes of variable fonts?

What have I tried you ask? Well, I've built this:

https://method.ac/font-tester/

The relevant code is this:

    input.addEventListener("input", function(){
      text.style["font-variation-settings"] = "'wght' " + input.value;
    })

But what I'm really looking to solve is something like...

    var fontAxes = [how?];
    fontAxes.forEach(axis => {
      var input = document.createElement("input");
      // customize input
      input.addEventListener("input", function(){
        // change axis
      })
    })

Solution

  • Retrieve axes data via LibFont.js

    As mentioned in his comment you can retrieve the design axes data parsing a font file with the LibFont library (the successor of font.js).

    Example 1: Get all design axes info

    // retrieve font data after all required assets are loaded (e.g for decompression)
    window.addEventListener('DOMContentLoaded', (e) => {
    
      // Example font: roboto flex
    /* api url: 
    https://fonts.googleapis.com/css2?family=Roboto+Flex:opsz,slnt,wdth,wght,GRAD,XTRA,YOPQ,YTAS,YTDE,YTFI,YTLC,YTUC@8..144,-10..0,25..151,100..1000,-200..150,323..603,25..135,649..854,-305..-98,560..788,416..570,528..760*/
      let fontUrl = "https://fonts.gstatic.com/s/robotoflex/v9/NaPccZLOBv5T3oB7Cb4i0zu6RME.woff2";
      let fontFamily = "variable Font";
      getAxisInfo(fontFamily, fontUrl);
    });
    
    function getAxisInfo(fontFamily, fontUrl) {
      let font = new Font(fontFamily, {
        skipStyleSheet: true
      });
      font.src = fontUrl;
      font.onload = (evt) => {
        let font = evt.detail.font;
        let otTables = font.opentype.tables;
        let fontname = otTables.name.get(1);
    
        // get variable font axes
        let axes = otTables.fvar.axes;
        let axesInfo = [];
        axes.forEach((axis, a) => {
          let axisName = axis.tag;
          let min = axis.minValue;
          let max = axis.maxValue;
          let defaultValue = axis.defaultValue;
          axesInfo.push(`name:"${axisName}"; min:${min}; max:${max}; default:${defaultValue};`);
    
        })
        let fontAxisString = fontname + '\n' + axesInfo.join('\n');
        fontAxisData.textContent = fontAxisString;
      }
    }
    <!-- add brotli decompression needed for woff2 -->
    <script src="https://cdn.jsdelivr.net/npm/lib-font@2.4.0/lib/unbrotli.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/lib-font@2.4.0/lib-font.browser.js" type="module"></script>
    
    <pre id="fontAxisData">

    This above example retrieves the font data for "Roboto Flex".

    The essential steps are:

    Get a font object specifying a font file path/url like so:

    let font = new Font('variableFontfamilyName');
    font.src = "https://fonts.gstatic.com/s/robotoflex/v9/NaPccZLOBv5T3oB7Cb4i0zu6RME.woff2"; 
    

    Retrieve axes info from the fvar table records:

    let otTables = font.opentype.tables;
    let axes = otTables.fvar.axes;
    

    Example 2: Inspect all design axes with preview

    // init first font
    window.addEventListener('DOMContentLoaded', (e) => {
      let firstFont = fontSelect.children[0];
      let fontUrl = firstFont.value;
      let fontFamily = firstFont.textContent.trim();
      loadFont(fontFamily, fontUrl);
    });
    
    upload.addEventListener('change', (e) => {
      // Encode the file using the FileReader API
      const file = e.target.files[0];
      const reader = new FileReader();
      reader.onloadend = () => {
        let fileName = file.name;
        let format = fileName.split('.').pop();
        let arrayBuffer = reader.result;
        loadFont(fileName, arrayBuffer)
      };
      reader.readAsArrayBuffer(file);
    })
    
    fontSelect.addEventListener('change', (e) => {
      let current = e.currentTarget;
      let fontUrl = current.value;
      let fontFamily = current.options[current.selectedIndex].textContent.trim();
      if (fontUrl) {
        loadFont(fontFamily, fontUrl);
      }
    })
    
    
    function loadFont(fontFamily, fontUrl) {
      let format = '';
      let fontSrc = '';
      let font = {};
      // if fontUrl is array buffer
      if (fontUrl.byteLength) {
        format = fontFamily.split('.').pop();
        format = format == 'ttf' ? 'truetype' : format;
        fontFamily = fontFamily.replaceAll('.' + format, '');
        font = new Font(fontFamily, {
          skipStyleSheet: true
        });
        font.fromDataBuffer(fontUrl, fontFamily);
        // array buffer to base64
        let base64 = arrayBufferToBase64(fontUrl);
        fontUrl = `data:font/${format};charset=utf-8;base64,${base64}`;
        fontSrc = `src: url(${fontUrl});`;
      }
      // if fontUrl is file url
      else {
        format = fontUrl.split('.').pop();
        format = format == 'ttf' ? 'truetype' : format;
        font = new Font(fontFamily);
        font.src = fontUrl;
        fontSrc = `src: url(${fontUrl} format('${format}'));`;
      }
    
    
      font.onload = (evt) => {
        let font = evt.detail.font;
        let otTables = font.opentype.tables;
        let fontname = otTables.name.get(1);
        fontNameCaption.textContent = `${fontname}`;
    
        // get variable font axes
        let axes = otTables.fvar.axes;
    
        let cssArr = [];
        AxesWrp.innerHTML = '';
        let axesInfo = [];
        axes.forEach((axis, a) => {
          let axisName = axis.tag;
          let min = axis.minValue;
          let max = axis.maxValue;
          let defaultValue = axis.defaultValue;
    
          //create range sliders according to min/max axes values
          let rangSliderLabel = document.createElement('label');
          let rangSlider = document.createElement('input')
          rangSlider.setAttribute('type', 'range');
          rangSlider.setAttribute('min', min);
          rangSlider.setAttribute('max', max);
          rangSlider.setAttribute('value', defaultValue);
          rangSlider.setAttribute('step', 0.1);
          rangSliderLabel.textContent = axisName + ': ';
          AxesWrp.appendChild(rangSliderLabel)
          rangSliderLabel.appendChild(rangSlider);
    
          // set default style
          cssArr.push(`"${axisName}" ${defaultValue}`);
          axesInfo.push(`name:"${axisName}"; min:${min}; max:${max}; default:${defaultValue};`);
    
          // update values by range sliders
          rangSlider.addEventListener('input', function(e) {
            cssArr[a] = `"${axisName}" ${e.currentTarget.value}`;
            fontPreview.style.fontVariationSettings = cssArr.join(', ');
            variationProp.textContent =
              `{font-variation-settings: ${fontPreview.style.fontVariationSettings} }`;
          })
        })
        console.log(axesInfo.join('\n'))
    
        //let fontVariationOptions = `font-variation-settings: ${cssArr.join(', ')}`;
        fontPreview.style.fontVariationSettings = cssArr.join(', ');
        //console.log(fontVariationOptions)
    
    
        variationProp.textContent =
          `{font-variation-settings: ${fontPreview.style.fontVariationSettings} }`;
    
        // append style for preview
        let fontStyle = 'normal';
        let fontWeight = 400;
        fontStyleEl.textContent = `
                @font-face{
                    font-family: "${fontFamily}";
                    ${fontSrc}
                    font-style: ${fontStyle};
                    font-weight: ${fontWeight};
                }
                .fontPreview{
                    font-family: "${fontFamily}";
                }
                `;
      }
    }
    
    
    function arrayBufferToBase64(buffer) {
      let binary = '';
      let bytes = new Uint8Array(buffer);
      for (let i = 0; i < bytes.byteLength; i++) {
        binary += String.fromCharCode(bytes[i]);
      }
      return btoa(binary);
    }
    body {
      font-family: sans-serif;
    }
    
    label {
      font-family: monospace;
      margin-right: 0.5em;
      display: inline-block;
    }
    
    .fontPreview {
      font-size: 10vmin;
      line-height: 1em;
      transition: 0.3s;
      border: 1px solid #ccc;
      outline: none;
    }
    <!-- add brotli decompression needed for woff2 -->
    <script src="https://cdn.jsdelivr.net/npm/lib-font@2.4.0/lib/unbrotli.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/lib-font@2.4.0/lib-font.browser.js" type="module"></script>
    
    <style id="fontStyleEl"></style>
    
    <p><label>Select font:</label>
      <select type="text" id="fontSelect" value="" list="fontList">
        <option value="https://fonts.gstatic.com/s/recursive/v35/8vIK7wMr0mhh-RQChyHuE2ZaGfn4-zal.woff2">Recursive
        </option>
        <option value="https://fonts.gstatic.com/s/robotoflex/v9/NaPccZLOBv5T3oB7Cb4i0zu6RME.woff2">Roboto Flex
        </option>
        <option value="https://fonts.gstatic.com/s/opensans/v34/mem8YaGs126MiZpBA-UFVZ0b.woff2">Open Sans
        </option>
      </select>
    
      <label>Load font: <input type="file" id="upload"></label>
    
    </p>
    <div id="AxesWrp"></div>
    
    <div class="preview">
      <p><span id="fontNameCaption"></span>
        <pre id="variationProp"></pre>
      </p>
      <div class="fontPreview" id="fontPreview" contenteditable>FontPreview: Type something</div>
    </div>

    Update 2023: get axes data via opentype.js

    Apparently opentype.js has included fvar table data to the font object.
    State 2023: opentype.js currently doesn't support any rendering functionality for variable fonts.
    It's also lacking support for CFF2 fonts.

    However you can adapt the previous function to work with opentype.js.

    let fontFamily = '';
    let fontSrc =
      "https://fonts.gstatic.com/s/robotoflex/v9/NaPccZLOBv5T3oB7Cb4i0zu6RME.woff2";
    let fontFaceSrc = fontSrc;
    
    // processs default font
    getVFAxes(fontSrc);
    
    upload.addEventListener('change', (e) => {
      // Encode the file using the FileReader API
      const file = e.target.files[0];
      const reader = new FileReader();
      reader.onloadend = () => {
        let fileName = file.name;
        let format = fileName.split('.').pop();
        let arrayBuffer = reader.result;
        let base64 = arrayBufferToBase64(arrayBuffer);
        fontFaceSrc = `data:font/${format};charset=utf-8;base64,${base64}`;
        console.log(fontSrc);
        getVFAxes(file);
      };
      reader.readAsArrayBuffer(file);
    })
    
    
    fontSelect.addEventListener('change', (e) => {
      let current = e.currentTarget;
      let fontUrl = current.value;
      fontFamily = current.options[current.selectedIndex].textContent.trim();
      if (fontUrl) {
        getVFAxes(fontUrl);
      }
    })
    
    async function getVFAxes(fontSrc) {
      let font = await loadFont(fontSrc);
      let fontFamily = font.tables.name.fontFamily.en;
      let axes = font.tables.fvar.axes;
      let cssArr = [];
    
      // reset axis sliders
      AxesWrp.innerHTML = "";
      let axesInfo = [];
    
      axes.forEach((axis, a) => {
        let axisName = axis.tag;
        let min = axis.minValue;
        let max = axis.maxValue;
        let defaultValue = axis.defaultValue;
    
    
        //create range sliders according to min/max axes values
        let rangSliderLabel = document.createElement("label");
        let rangSlider = document.createElement("input");
        rangSlider.setAttribute("type", "range");
        rangSlider.setAttribute("min", min);
        rangSlider.setAttribute("max", max);
        rangSlider.setAttribute("value", defaultValue);
        rangSlider.setAttribute("step", 0.1);
        rangSliderLabel.textContent = axisName + ": ";
        AxesWrp.appendChild(rangSliderLabel);
        rangSliderLabel.appendChild(rangSlider);
    
        // set default style
        cssArr.push(`"${axisName}" ${defaultValue}`);
        axesInfo.push(
          `name:"${axisName}"; min:${min}; max:${max}; default:${defaultValue};`
        );
    
        // update values by range sliders
        rangSlider.addEventListener("input", function(e) {
          cssArr[a] = `"${axisName}" ${e.currentTarget.value}`;
          fontPreview.style.fontVariationSettings = cssArr.join(", ");
          variationProp.textContent = `{font-variation-settings: ${fontPreview.style.fontVariationSettings}}`;
        });
    
        fontPreview.style.fontVariationSettings = cssArr.join(', ');
        variationProp.textContent =
          `{font-variation-settings: ${fontPreview.style.fontVariationSettings} }`;
    
        // append style for preview
        let fontStyle = 'normal';
        let fontWeight = 400;
    
        fontStyleEl.textContent = `
                @font-face{
                    font-family: "${fontFamily}";
                    src: url(${fontFaceSrc});
                    font-style: ${fontStyle};
                    font-weight: ${fontWeight};
                }
                .fontPreview{
                    font-family: "${fontFamily}";
                }
                `;
    
      });
    }
    
    /**
     * opentype.js helper
     * Based on @yne's comment
     * https://github.com/opentypejs/opentype.js/issues/183#issuecomment-1147228025
     * will decompress woff2 files
     */
    async function loadFont(src) {
      let buffer = {};
      let font = {};
      let ext = "woff2";
      let url;
    
      // 1. is file
      if (src instanceof Object) {
        // get file extension to skip woff2 decompression
        let filename = src.name.split(".");
        ext = filename[filename.length - 1];
        buffer = await src.arrayBuffer();
      }
    
      // 2. is base64 data URI
      else if (/^data/.test(src)) {
        // is base64
        let data = src.split(";");
        ext = data[0].split("/")[1];
    
        // create buffer from blob
        let srcBlob = await (await fetch(src)).blob();
        buffer = await srcBlob.arrayBuffer();
      }
    
      // 3. is url
      else {
        // if google font css - retrieve font src
        if (/googleapis.com/.test(src)) {
          ext = "woff2";
          src = await getGoogleFontUrl(src);
        }
    
        // might be subset - no extension
        let hasExt =
          src.includes(".woff2") ||
          src.includes(".woff") ||
          src.includes(".ttf") ||
          src.includes(".otf") ?
          true :
          false;
        url = src.split(".");
        ext = hasExt ? url[url.length - 1] : "woff2";
    
        let fetchedSrc = await fetch(src);
        buffer = await fetchedSrc.arrayBuffer();
      }
    
      // decompress woff2
      if (ext === "woff2") {
        buffer = Uint8Array.from(Module.decompress(buffer)).buffer;
      }
    
      // parse font
      font = opentype.parse(buffer);
      return font;
    }
    
    
    function arrayBufferToBase64(buffer) {
      let binary = '';
      let bytes = new Uint8Array(buffer);
      for (let i = 0; i < bytes.byteLength; i++) {
        binary += String.fromCharCode(bytes[i]);
      }
      return btoa(binary);
    }
    body {
      font-family: sans-serif;
    }
    
    label {
      font-family: monospace;
      margin-right: 0.5em;
      display: inline-block;
    }
    
    .fontPreview {
      font-size: 10vmin;
      line-height: 1em;
      transition: 0.3s;
      border: 1px solid #ccc;
      outline: none;
    }
    <style id="fontStyleEl"></style>
    
    <p><label>Select font:</label>
      <select type="text" id="fontSelect" value="" list="fontList">
        <option value="https://fonts.gstatic.com/s/recursive/v35/8vIK7wMr0mhh-RQChyHuE2ZaGfn4-zal.woff2">Recursive
        </option>
        <option value="https://fonts.gstatic.com/s/robotoflex/v9/NaPccZLOBv5T3oB7Cb4i0zu6RME.woff2">Roboto Flex
        </option>
        <option value="https://fonts.gstatic.com/s/opensans/v34/mem8YaGs126MiZpBA-UFVZ0b.woff2">Open Sans
        </option>
      </select>
    
      <label>Load font: <input type="file" id="upload"></label>
    
    </p>
    <div id="AxesWrp"></div>
    
    <div class="preview">
      <p><span id="fontNameCaption"></span>
        <pre id="variationProp"></pre>
      </p>
      <div class="fontPreview" id="fontPreview" contenteditable>FontPreview: Type something</div>
    </div>
    
    
    
    <!-- fontello woff2 to truetype conversion -->
    <script src="https://unpkg.com/wawoff2@2.0.1/build/decompress_binding.js"></script>
    <script src='https://cdn.jsdelivr.net/npm/opentype.js@latest/dist/opentype.min.js'></script>