4

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
      })
    })
4
  • Vanilla JS? No. The native APis for fonts are incredibly terrible. You're better off either writing a small parser for FVAR information, or using something like opentype.js or fontkit Commented Nov 11, 2019 at 5:33
  • github.com/w3c/csswg-drafts/issues/520 Commented Nov 11, 2019 at 5:53
  • 1
    In the mean time, I sat down to revisit Font.js which at this point will quite happily report which axes exist for variable fonts in ttf/otf, plain/woff/woff2 versions (e.g. in Adobe Source Code Pro we just get wght). The code is currently in the remastered branch of github.com/Pomax/Font.js but will replace master in a few days when I add in the remaining parsing for the common table format and add in the text measurement functionality that the original Font.js was for. Commented Nov 15, 2019 at 1:20
  • @Mike'Pomax'Kamermans Oh that looks really promising, I'll wait for the release since I'm not in a rush. Commented Nov 19, 2019 at 20:06

1 Answer 1

1

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,[email protected],-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/[email protected]/lib/unbrotli.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/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/[email protected]/lib/unbrotli.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/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/[email protected]/build/decompress_binding.js"></script>
<script src='https://cdn.jsdelivr.net/npm/opentype.js@latest/dist/opentype.min.js'></script>

Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.