Search code examples
fonts

Subset a variable font to only include required axis


I'm looking into Variable Fonts. Contrary to the claims, the variable fonts I've looked at have massive file sizes. One is 40 times the size the 'static' weight of the same font.

To reduce the file size of Variable Fonts, do Adobe Fonts or Google Fonts, provide the option to subset the variable font file – so that it only includes the required axis?

For example, the font I'd like to use has the width axis and the slant axis – neither of which I need. Can these be removed from the variable font served to my site?

UPDATE Adobe Fonts provides the complete Variable Font file, with all the axis. I have not found a way to remove axis from a Variable Font file provided by Adobe Fonts. Meaning that the Variable Fonts provided by Adobe Fonts are often massive and far larger than a handful of the same static fonts. Making their Variable Fonts an unattractive option.


Solution

  • Get axes subsets via query parameter

    You can retrieve subsets by specifying the appropriate query parameters.

    For instance: get Roboto Flex – only weight axis:

    https://fonts.googleapis.com/css2?family=Roboto+Flex:wght@100..900
    

    Unfortunately, it's not very convenient to get the correct URL and some browsers might get static font (e.g. Opera although it supports variable fonts flawlessly).

    Variable or static version?

    Some families are available both as static and variable version – using the same family name. Others are only available under a variable specific family name e.g "Roboto Flex" (VF), "Roboto" (static).

    Example: Open Sans
    https://fonts.googleapis.com/css2?family=Open+Sans:wght@300..800
    separating the weight values by ".." periods will return the variable font @font-face rules.

    BTW: you need to know the range of supported weight. E.g 100..1000 won't work as it exceeds the supported weight range of "Open Sans".
    Use the type tester tab in google font's UI to get the correct ranges.

    https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;400;500;600;700;800
    We#re using ";" semicolon as separator – it returns the static fonts for weights 300–900;

    Example: Roboto
    https://fonts.googleapis.com/css2?family=Roboto+Flex:wdth,wght@25..151,100..900
    works!

    https://fonts.googleapis.com/css2?family=Roboto:wdth,wght@25..151,100..900
    doesn't work!

    /* latin */
    @font-face {
      font-family: 'Roboto Flex';
      font-style: normal;
      font-weight: 100 900;
      font-stretch: 100%;
      font-display: swap;
      src: url(https://fonts.gstatic.com/s/robotoflex/v9/NaNNepOXO_NexZs0b5QrzlOHb8wCikXpYqmZsWI-__OGfttPZktqc2VdZ80KvCLZaPcSBZtOx2MifRuWR28sPJtUMbsFEK6cRrleUx9Xgbm3WLHa_F4Ep4Fm0PN19Ik5Dntczx0wZGzhPlL1YNMYKbv9_1IQXOw7AiUJVXpRJ6cXW4O8TNGoXjC79QRyaLshNDUf3e0O-gn5rrZCu20YNau4OPE.woff2) format('woff2');
      unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
    }
    
    body{
      font-family: 'Roboto Flex';
    }
    
    .w900{
      font-weight:900;
    }
    
    .w100{
      font-weight:100;
    }
    <h1 class="w900">Head weight 900</h1>
    <p class="w100">Test paragraph weight 100</p>

    Testing the current font axes

    For some reasons the API will always keep the Parametric Thick Stroke axis (XOPQ).

    let fontFamily = '';
    let fontSrc =
      "https://fonts.gstatic.com/s/robotoflex/v9/NaPccZLOBv5T3oB7Cb4i0zu6RME.woff2";
    getVFAxes(fontSrc);
    
    
    upload.addEventListener('change', (e) => {
      // Encode the file using the FileReader API
      const file = e.target.files[0];
      getVFAxes(file);
    })
    
    
    inputFont.addEventListener('input', (e) => {
      let current = e.currentTarget;
      let fontUrl = current.value;
      let datalistId = current.getAttribute('list');
      let datalistOption = document.querySelector(`#${datalistId} option[value="${current.value}"]`);
      fontFamily = datalistOption ? datalistOption.textContent.trim() : 'Test font';
      if (fontUrl) {
        getVFAxes(fontUrl);
      }
    })
    
    
    
    async function getVFAxes(fontSrc) {
      let font = await loadFont(fontSrc);
      let fontFamily = font.tables.name.fontFamily.en;
    
      let axes = [];
    
      try {
        axes = font.tables.fvar.axes;
      } catch {
        alert('Font has no axes data - is not a variable font');
        console.log(font)
        return false;
      }
      let cssArr = [];
    
      AxesWrp.innerHTML = "";
      console.log('reset', fontFamily)
      console.log(axes)
      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;
    
        /*
        let base64 = arrayBufferToBase64(fontSrc);
        let fontFaceUrl = `data:font/${format};charset=utf-8;base64,${base64}`;
         fontFaceUrl = `src: url(${fontUrl});`;
         */
    
        fontStyleEl.textContent = `
                @font-face{
                    font-family: "${fontFamily}";
                    src: url(${fontSrc});
                    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, options = {}) {
      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';
    
          console.log(src);
    
          src = await getGoogleFontUrl(src, options);
        }
    
    
        // 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;
    }
    
    
    /**
     * load google font subset
     * containing all glyphs used in document
     */
    async function getGoogleFontSubsetFromContent(url, documentText = '') {
    
      // get all characters used in body
      documentText = documentText ? documentText : document.body.innerText.trim()
        .replace(/[\n\r\t]/g, "")
        .replace(/\s{2,}/g, " ")
        .replaceAll(' ', "");
      url = url + '&text=' + encodeURI(documentText);
    
      let fetched = await fetch(url);
      let res = await fetched.text();
      let src = res.match(/[^]*?url\((.*?)\)/)[1];
    
      return src;
    }
    
    /**
     * load fonts from google helper
     */
    async function getGoogleFontUrl(url, options = {}) {
      let src;
      let subset = options.subset ? options.subset : 'latin';
      let subsetText = options.subsetText ? options.subsetText : '';
    
      // get subset based on used characters
      if (subsetText) {
        src = getGoogleFontSubsetFromContent(url, subsetText);
        return src;
      }
      let fetched = await fetch(url);
      let res = await fetched.text();
    
      // get language subsets
      let subsetObj = {};
      let subsetRules = res.split("/*").filter(Boolean);
    
      for (let i = 0; i < subsetRules.length; i++) {
        let subsetRule = subsetRules[i];
        let rule = subsetRule.split("*/");
        let subset = rule[0].trim();
        let src = subsetRule.match(/[^]*?url\((.*?)\)/)[1];
        subsetObj[subset] = src;
      }
      src = subsetObj[subset];
    
      if (src === undefined) {
        console.log(subsetRules);
        src = subsetRules[0].match(/[^]*?url\((.*?)\)/)[1];
      }
    
      return src;
    }
    
    
    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);
    }
    
    
    keepDatalistOptions();
    
    function keepDatalistOptions(selector = "") {
      // select all input fields by datalist attribute or by class/id
      selector = !selector ? "input[list]" : selector;
      let datalistInputs = document.querySelectorAll(selector);
      if (datalistInputs.length) {
        for (let i = 0; i < datalistInputs.length; i++) {
          let input = datalistInputs[i];
          input.addEventListener("input", function(e) {
            e.target.setAttribute("placeholder", e.target.value);
            e.target.blur();
          });
          input.addEventListener("focus", function(e) {
            e.target.setAttribute("placeholder", e.target.value);
            e.target.value = "";
          });
          input.addEventListener("blur", function(e) {
            e.target.value = e.target.getAttribute("placeholder");
          });
        }
      }
    }
    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;
    }
    <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>
    
    
    <style id="fontStyleEl"></style>
    
    
    
    <p>
      <label>Font file URL: <input class="inputChange" id="inputFont" type="text" value="https://fonts.googleapis.com/css2?family=Roboto+Flex:wdth,wght@25..151,100..900" list="fontList"></label>
    
      <datalist id="fontList">
        <option value="https://fonts.gstatic.com/s/robotoflex/v9/NaPccZLOBv5T3oB7Cb4i0zu6RME.woff2">Roboto Flex - all Axes
        </option>
        <option value="https://fonts.gstatic.com/s/robotoflex/v9/NaNeepOXO_NexZs0b5QrzlOHb8wCikXpYqmZsWI-__OGfttPZktqc2VdZ80KvCLZaPcSBZtOx2MifRuWR28sPJtUMbsFEK6cRrleUx9Xgbm3WLHa_F4Ep4Fm0PN19Ik5Dntczx0wZGzhPlL1YNMYKbv9_1IQXOw7AiUJVXpRJ6cXW4O8TNGoXjC79QRyaLshNDUf9-EmFw.woff2" title="https://fonts.googleapis.com/css2?family=Roboto+Flex:wdth,wght@25..151,100..900">Roboto Flex - width and weight</option>
        <option value="https://fonts.gstatic.com/s/robotoflex/v9/NaNNepOXO_NexZs0b5QrzlOHb8wCikXpYqmZsWI-__OGfttPZktqc2VdZ80KvCLZaPcSBZtOx2MifRuWR28sPJtUMbsFEK6cRrleUx9Xgbm3WLHa_F4Ep4Fm0PN19Ik5Dntczx0wZGzhPlL1YNMYKbv9_1IQXOw7AiUJVXpRJ6cXW4O8TNGoXjC79QRyaLshNDUf3e0O-gn5rrZCu20YNau4OPE.woff2">Roboto Flex - only weight</option>
    
        <option value="https://fonts.gstatic.com/s/opensans/v34/mem8YaGs126MiZpBA-UFVZ0b.woff2">Open Sans
        </option>
    
    <option value="https://fonts.googleapis.com/css2?family=Open+Sans&display=swap&text=abc123
    ">Open Sans static</option>
    
      <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>

    Filesize:

    • All Axes: 307 KB
      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
    • weight + width: 62 KB
      https://fonts.googleapis.com/css2?family=Roboto+Flex:wdth,wght@25..151,100..900
    • weight: 38 KB
      https://fonts.googleapis.com/css2?family=Roboto+Flex:wght@100..900
    • Roboto (static) - weight 400,500,600,700: 4 x 16KB = 64KB
      https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;600;700

    Static fonts might still be smaller in filesize

    It really depends on the total number of weights and styles.

    Further reading