Is there a "fast" way to get info about variable Google Fonts?

549 views Asked by At

I am building a UI as part of a product to make it easy to choose, select and style Google fonts. I am challenged by variable fonts because I cannot find a good way to get info about these. The developer API provides metadata for all Google Fonts via a large JSON string. However, it does not seem to contain any data that would allow a developer to discern which fonts are variable. They all “appear” to be standard fonts.

Is there a fast way to get such data? By fast, I am talking about something similar to Google Font’s developer API, but with data for the various variable fonts that would include:

  • Which fonts are variable?
  • Which axes do the variable fonts ship with?

Currently, the only recommended approach I’ve seen for exploring variable fonts and their axes is to load the fonts into a web page and use Firefox’s Font editor in the developer tools to manually get the data. But with the current 112 variable fonts in Google Fonts, it could take days to collect this info. So my question is: Is there a faster way to get meta data for the variable fonts in Google Fonts?

3

There are 3 answers

4
Stephen Miller On

I love the answer from Stranger1586. But I really also need data on the steps for each axis in order to properly build UI elements such as sliders. So I decided to write a scraper to scrape the data from https://fonts.google.com/variablefonts. That page contains updated data on all variable fonts and all supported axes according to Google Font's GitHub page.

The scraper creates a JSON file with axes data for each font family. I hope it might be helpful to others having the same need. Here is the code:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

from selenium import webdriver
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.common.by import By
from selenium.common.exceptions import TimeoutException
from selenium.webdriver.firefox.options import Options

from bs4 import BeautifulSoup
import json

def get_variable_fonts_data():
    print('Opening: Google Variable Fonts page...')
    options = Options()
    options.headless = True
    gecko_path = r'D:\Anaconda3\envs\py37\Lib\site-packages\helium\_impl\webdrivers\windows\geckodriver.exe'
    url = 'https://fonts.google.com/variablefonts'
    browser = webdriver.Firefox(options=options, executable_path=gecko_path)
    browser.get(url)
    timeout = 10  # seconds

    # Wait for the table element as it is not part of the page source but is generated with JavaScript
    try:
        WebDriverWait(browser, timeout).until(EC.presence_of_element_located((By.TAG_NAME, 'table')))
        print('Generating font table')
    except TimeoutException:
        print("Loading took too much time!")

    soup = BeautifulSoup(browser.page_source, 'html.parser')
    table = soup.find('table')
    table_head = table.find('thead').find('tr')
    header_values = []
    for cell in table_head.find_all('th'):
        header_values.append(cell.encode_contents().decode("utf-8").strip())
    table_body = table.find('tbody')
    variable_fonts_data = {}
    for row in table_body.find_all('tr'):
        axis_data = {}
        cells = row.find_all('td')
        font_family_name = cells[0].find('a').encode_contents().decode("utf-8").strip()
        if not (font_family_name in variable_fonts_data):
            variable_fonts_data[font_family_name] = {'Axes': {}}

        axis_data[header_values[2]] = cells[2].encode_contents().decode("utf-8").strip()  # Default
        axis_data[header_values[3]] = cells[3].encode_contents().decode("utf-8").strip()  # Min
        axis_data[header_values[4]] = cells[4].encode_contents().decode("utf-8").strip()  # Max
        axis_data[header_values[5]] = cells[5].encode_contents().decode("utf-8").strip()  # Step

        variable_fonts_data[font_family_name]['Axes'][cells[1].encode_contents().decode("utf-8").strip()] = axis_data

    return variable_fonts_data


with open('google_variable_fonts.json', 'w') as fonts_file:
    json.dump(get_variable_fonts_data(), fonts_file) 
1
Stranger1586 On

I am working on a font picker plugin and I ran into a similar problem, so I started investigating the google fonts main distribution site until I found what I was looking for. Google's fonts site executes a call to the following API endpoint.

https://fonts.google.com/metadata/fonts

Which returns the following text file.

)]}'{"axisRegistry": [
{
  "tag": "FILL",
  "displayName": "Fill",
  "min": 0.0,
  "defaultValue": 0.0,
  "max": 1.0,
  "precision": -2,
  "description": "The Fill axis is intended to provide a treatment of the design that fills in transparent forms with opaque ones (and sometimes interior opaque forms become transparent, to maintain contrasting shapes). Transitions often occur from the center, a side, or a corner, but can be in any direction. This can be useful in animation or interaction to convey a state transition. The numbers indicate proportion filled, from 0 (no treatment) to 1 (completely filled).",
  "fallbacks": [
    {
      "name": "Normal",
      "value": 0.0
    },
    {
      "name": "Filled",
      "value": 1.0
    }
  ]
} ...],"familyMetadataList": [{
  "family": "Alegreya",
  "displayName": null,
  "category": "Serif",
  "size": 425570,
  "subsets": [
    "menu",
    "cyrillic",
    "cyrillic-ext",
    "greek",
    "greek-ext",
    "latin",
    "latin-ext",
    "vietnamese"
  ],
  "fonts": {
    "400": {
      "thickness": 4,
      "slant": 1,
      "width": 6,
      "lineHeight": 1.361
    },
    "400i": {
      "thickness": 4,
      "slant": 4,
      "width": 6,
      "lineHeight": 1.361
    },
    "500": {
      "thickness": null,
      "slant": null,
      "width": null,
      "lineHeight": 1.361
    },
    "500i": {
      "thickness": null,
      "slant": null,
      "width": null,
      "lineHeight": 1.361
    },
    "600": {
      "thickness": null,
      "slant": null,
      "width": null,
      "lineHeight": 1.361
    },
    "600i": {
      "thickness": null,
      "slant": null,
      "width": null,
      "lineHeight": 1.361
    },
    "700": {
      "thickness": 7,
      "slant": 1,
      "width": 7,
      "lineHeight": 1.361
    },
    "700i": {
      "thickness": 6,
      "slant": 4,
      "width": 6,
      "lineHeight": 1.361
    },
    "800": {
      "thickness": null,
      "slant": null,
      "width": null,
      "lineHeight": 1.361
    },
    "800i": {
      "thickness": null,
      "slant": null,
      "width": null,
      "lineHeight": 1.361
    },
    "900": {
      "thickness": 8,
      "slant": 1,
      "width": 7,
      "lineHeight": 1.361
    },
    "900i": {
      "thickness": 8,
      "slant": 4,
      "width": 6,
      "lineHeight": 1.361
    }
  },
  "axes": [
    {
      "tag": "wght",
      "min": 400.0,
      "max": 900.0,
      "defaultValue": 400.0
    }
  ],
  "unsupportedAxes": [],
  "designers": [
    "Juan Pablo del Peral",
    "Huerta Tipográfica"
  ],
  "lastModified": "2021-02-11",
  "dateAdded": "2011-12-19",
  "popularity": 159,
  "trending": 828,
  "defaultSort": 164,
  "androidFragment": null,
  "isNoto": false
}...],...}

Please note that while the above looks like a JSON file, it will be treated as a text file, so you will have to remove this part )]}' from the top of the text file, so you can then parse it as a JSON file. The only top-level property that matters (as far as your use case is concerned) is the "familyMetadataList" property, as the name implies it includes all the fonts metadata, which includes the axes any given font has. You will have to loop on the "familyMetadataList" prop and see if the font's axes member has an array that is not empty, from there we can deduce that it is a variable font. You can do something as simple as this to figure out which font is variable.

const variableFonts=[];
const googleFontJSON = {
 "familyMetadataList": [
 {
  "family": "Alegreya",
  "displayName": null,
  "category": "Serif",
  "size": 425570,
  "subsets": [
    "menu",
    "cyrillic",
    "cyrillic-ext",
    "greek",
    "greek-ext",
    "latin",
    "latin-ext",
    "vietnamese"
  ],
  "fonts": {
    "400": {
      "thickness": 4,
      "slant": 1,
      "width": 6,
      "lineHeight": 1.361
    },
    "400i": {
      "thickness": 4,
      "slant": 4,
      "width": 6,
      "lineHeight": 1.361
    },
    "500": {
      "thickness": null,
      "slant": null,
      "width": null,
      "lineHeight": 1.361
    },
    "500i": {
      "thickness": null,
      "slant": null,
      "width": null,
      "lineHeight": 1.361
    },
    "600": {
      "thickness": null,
      "slant": null,
      "width": null,
      "lineHeight": 1.361
    },
    "600i": {
      "thickness": null,
      "slant": null,
      "width": null,
      "lineHeight": 1.361
    },
    "700": {
      "thickness": 7,
      "slant": 1,
      "width": 7,
      "lineHeight": 1.361
    },
    "700i": {
      "thickness": 6,
      "slant": 4,
      "width": 6,
      "lineHeight": 1.361
    },
    "800": {
      "thickness": null,
      "slant": null,
      "width": null,
      "lineHeight": 1.361
    },
    "800i": {
      "thickness": null,
      "slant": null,
      "width": null,
      "lineHeight": 1.361
    },
    "900": {
      "thickness": 8,
      "slant": 1,
      "width": 7,
      "lineHeight": 1.361
    },
    "900i": {
      "thickness": 8,
      "slant": 4,
      "width": 6,
      "lineHeight": 1.361
    }
  },
  "axes": [
    {
      "tag": "wght",
      "min": 400.0,
      "max": 900.0,
      "defaultValue": 400.0
    }
  ],
  "unsupportedAxes": [],
  "designers": [
    "Juan Pablo del Peral",
    "Huerta Tipográfica"
  ],
  "lastModified": "2021-02-11",
  "dateAdded": "2011-12-19",
  "popularity": 159,
  "trending": 828,
  "defaultSort": 164,
  "androidFragment": null,
  "isNoto": false
},
    {
      "family": "Alegreya SC",
      "displayName": null,
      "category": "Serif",
      "size": 381295,
      "subsets": [
        "menu",
        "cyrillic",
        "cyrillic-ext",
        "greek",
        "greek-ext",
        "latin",
        "latin-ext",
        "vietnamese"
      ],
      "fonts": {
        "400": {
          "thickness": 4,
          "slant": 1,
          "width": 7,
          "lineHeight": 1.361
        },
        "400i": {
          "thickness": 4,
          "slant": 4,
          "width": 7,
          "lineHeight": 1.361
        },
        "500": {
          "thickness": null,
          "slant": null,
          "width": null,
          "lineHeight": 1.361
        },
        "500i": {
          "thickness": null,
          "slant": null,
          "width": null,
          "lineHeight": 1.361
        },
        "700": {
          "thickness": 6,
          "slant": 1,
          "width": 7,
          "lineHeight": 1.361
        },
        "700i": {
          "thickness": 6,
          "slant": 3,
          "width": 7,
          "lineHeight": 1.361
        },
        "800": {
          "thickness": null,
          "slant": null,
          "width": null,
          "lineHeight": 1.361
        },
        "800i": {
          "thickness": null,
          "slant": null,
          "width": null,
          "lineHeight": 1.361
        },
        "900": {
          "thickness": 8,
          "slant": 1,
          "width": 7,
          "lineHeight": 1.361
        },
        "900i": {
          "thickness": 8,
          "slant": 3,
          "width": 7,
          "lineHeight": 1.361
        }
      },
      "axes": [],
      "unsupportedAxes": [],
      "designers": [
        "Juan Pablo del Peral",
        "Huerta Tipográfica"
      ],
      "lastModified": "2021-03-24",
      "dateAdded": "2011-12-19",
      "popularity": 436,
      "trending": 249,
      "defaultSort": 443,
      "androidFragment": null,
      "isNoto": false
    }
]}; // The array of font meta data
googleFontJSON.familyMetadataList.forEach(font => {     
  if (font.axes.length) {
    font.isVariable=true;
  } else {
    font.isVariable=false;
  }
});
console.log(googleFontJSON);

How you analyze the data is of course entirely your own prerogative. Good luck with your project, Mr.Steven. You can also acquire more information about any given variable font's axes step through the axis registry prop found JSON file found at https://fonts.google.com/metadata/fonts. Simply examine the precision prop. For example, axes with a 0.1 step like "opsz" and "wdth" have their precision set to -1, axes with a 0.01 step like "CASL" and "MONO" have their precision set to -2.

  "axisRegistry": [
    {
      "tag": "opsz",
      "displayName": "Optical size",
      "min": 6.0,
      "defaultValue": 14.0,
      "max": 144.0,
      "precision": -1, //<=== Here
      "description": "Adapt the ...",
      "fallbacks": [
        {
          "name": "6pt",
          "value": 6.0
        },
        {
          "name": "7pt",
          "value": 7.0
        }...
      ]
    },...

0
herrstrietzel On

Some additional thoughts about retrieving data from the google-fonts-meta-URL or via Dev API.

The google fonts meta source is at first glance quite appealing as it includes all relevant axes data – in contrast to the dev API response also the default axes values like 'wdth ':400 (duh!)

But this data source is not intended for open API usage.

Therefore some caveats:

  • we can't just fetch this data source via fetch due to CORS restrictions. A huge drawback if you aim at a pure JS approach – you'll need a scraping approach (as described by Stephen Miller)
  • there's no reason why google shouldn't change the data structure significantly – admittedly, the official dev API can also be changed completely – but more likely in a new API version providing some legacy support. However, the meta data source is certainly less future-proof.

Unfancy scraping via php/curl proxy

If your app is published on a apache/nginx server you might easily use a simple php script like this

scrapeGoogeFontMeta.php

<?php
$url = "https://fonts.google.com/metadata/fonts";

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
$data = curl_exec($ch);
echo $data;
curl_close($ch);

?>

and retrieve the data in JS like so:

let urlMetaDataCached="https://cdn.jsdelivr.net/gh/herrstrietzel/fonthelpers@main/json/gfontsMeta.json";
let urlMetaDataProxy ="https://your-domain.com/scrapeGoogeFontMeta.php";
let urlData = urlMetaDataCached;


window.addEventListener("DOMContentLoaded", async (e) => {

  // fetch font JSON
  let fontList = await (await fetch(urlData)).json();

  // google fonts meta or dev API structure
  let items = fontList.familyMetadataList;
  
  // save axes defaults to new object
  let axesDefaults = fontList.axisRegistry;
  let axesDefaultsObj = {};
  axesDefaults.forEach(item=>{
     axesDefaultsObj[item.tag]  =  item
  })  

  // filter: only variable fonts
  items = items.filter((item) => item.axes && item.axes.length);
  
  // render font list
  let listHTML='';
  items.forEach(item=>{
    let axes = item.axes;
    item.axes.forEach(axis=>{
      axis.precision = axesDefaultsObj[axis.tag].precision
      axis.step = axis.precision === -1 ? 0.1 : 1
    })
    //let defaults = 
    let axesString = JSON.stringify(item.axes)
    listHTML +=`<li>${item.family}: ${axesString}</li>`
  })
  
  fontInfoList.insertAdjacentHTML('beforeend', listHTML )
  
});
<ul id="fontInfoList"></ul>

The example above takes a static cdn hosted copy of the data json to prevent loading issues in SO snippets.

As commented by Peter Constable you don't necessarily need the step values for axis slider UI. It is rather a design choice by google to allow more fine grained changes for axes with a smaller range e.g "opsz".

Google fonts dev API example

As mentioned before, the google fonts dev api doesn't return info about default axes values. However you can retrieve all data client-side once you aquired a API key

async function getAllVariableFonts(apiKey = '', format = 'woff2', apiUrlStatic = '') {

  let apiURL = `https://www.googleapis.com/webfonts/v1/webfonts?capability=VF&capability=${format}&sort=style&key=${apiKey}`;

  // switch between API and static src
  let listUrl = apiKey ? apiURL : apiUrlStatic;

  // fetch font JSON
  let listObj = await (await fetch(listUrl)).json();

  // get only VF items
  let items = listObj.items;
  items = items.filter(item => item.axes && item.axes.length);
  return items;
}



function getGoogleFontUrl(font) {

  // replace whitespace
  let familyQuery = font.family.replaceAll(' ', '+');
  let gfontBase = `https://fonts.googleapis.com/css2?family=`;


  // check variants 
  let variants = [...new Set(font.variants.filter(Boolean))];
  let stylesItalic = variants.filter(variant => variant.includes('italic'));
  let stylesRegular = variants.filter(variant => !variant.includes('italic'));


  // sort axes alphabetically - case sensitive ([a-z],[A-Z])
  let axes = font.axes;
  axes = [axes.filter(item => item.tag.toLowerCase() === item.tag), axes.filter(item => item.tag.toUpperCase() === item.tag)].flat();
  let ranges = axes.map(val => {
    return val.start + '..' + val.end
  });
  let tuples = [];

  //  italic and regular
  if (stylesItalic.length && stylesRegular.length) {
    tuples.push('ital');
    rangeArr = [];
    for (let i = 0; i < 2; i++) {
      rangeArr.push(`${i},${ranges.join(',')}`)
    }
  }
  // only italic
  else if (stylesItalic.length && !stylesRegular.length) {
    tuples.push('ital');
    rangeArr = [];
    rangeArr.push(`${1},${ranges.join(',')}`);
  }

  // only regular
  else {
    rangeArr = [];
    rangeArr.push(`${ranges.join(',')}`)
  }

  // add axes tags to tuples
  axes.map(val => {
    return tuples.push(val.tag)
  });
  query = tuples.join(',') + '@' + rangeArr.join(';') + '&display=swap';

  let url = `${gfontBase}${familyQuery}:${query}`
  return url;
}



function updatePreview(item, googleFontUrl) {

  legend.textContent = `Preview: ${item.family}`

  // add css
  gfCss.textContent = `
  @import '${googleFontUrl}';
  .preview{
    font-family: "${item.family}";
    font-size: 12vmin;
  }`;

  let axes = item.axes;
  styleSelect.innerHTML = ''
  let fontVariationSettings = {};


  let hasItalic = item.variants.includes('italic')
  if (hasItalic) {
    let checkbox = document.createElement('input')
    let checkboxLabel = document.createElement('label')
    checkboxLabel.textContent = 'Italic '
    checkbox.type = 'checkbox'
    checkboxLabel.append(checkbox)
    styleSelect.append(checkboxLabel)

    checkbox.addEventListener('click', e => {
      preview.style.fontStyle = checkbox.checked ? 'italic' : 'normal'
    });
  }


  axes.forEach(axis => {
    let label = document.createElement('label')
    let input = document.createElement('input')
    input.type = 'range'
    input.min = axis.start
    input.max = axis.end
    input.value = axis.start

    fontVariationSettings[axis.tag] = axis.start;
    label.textContent = `${axis.tag}: ${axis.start}–${axis.end} `;
    styleSelect.append(label, input)

    // apply style
    input.addEventListener('input', e => {
      let val = e.currentTarget.value;
      fontVariationSettings[axis.tag] = val;
      let cssVar = []
      for (tag in fontVariationSettings) {
        cssVar.push(`"${tag}" ${fontVariationSettings[tag]}`)
      }
      preview.style.fontVariationSettings = cssVar.join(', ')
    })
  })

}


function showLink(target, url) {
  target.innerHTML = '';
  let link = `<a href="${url}">${url}</a>`;
  target.insertAdjacentHTML('beforeend', link)
}


function populateDatalist(target, list) {
  let fonts = list;
  let datalistOptions = '';
  fonts.forEach(font => {
    //only VF
    if (font.axes) {
      datalistOptions += `<option >${font.family}</option>`;
    }
  });
  target.insertAdjacentHTML('beforeend', datalistOptions);
}
body {
  font-family: sans-serif;
}

* {
  box-sizing: border-box;
}

#inputFonts {
  font-size: 1.5em;
  width: 100%;
  padding: 0.3em;
}

textarea {
  width: 100%;
  min-height: 20em;
}

input {
  margin-right: 1em;
}

legend {
  font-weight: 700
}

fieldset {
  margin-bottom: 1em;
}
<!-- variable font css for preview -->
<style id="gfCss">
  .preview {}
</style>


<body>
  <!-- partial:index.partial.html -->
  <h1>Get variable font CSS URLS and axes</h1>

  <fieldset>
    <legend>Search font</legend>
    <input type="text" list="datalistFonts" id="inputFonts" placeholder="Search google font">
    <p id="cssUrls"></p>

    <!-- autocomplete -->
    <datalist id="datalistFonts">
    </datalist>
  </fieldset>


  <fieldset>
    <legend id="legend">Preview</legend>
    <div id="preview" class="preview">
      Hamburglefonstiv
    </div>
    <div id="styleSelect"></div>
  </fieldset>


  <script>
    apiKey = '';
    let apiUrlStatic = "https://cdn.jsdelivr.net/gh/herrstrietzel/fonthelpers@main/json/gfontsAPI.json";

    // init
    window.addEventListener('DOMContentLoaded', async(e) => {

      /**
       * get all google fonts from API
       * filter only variable fonts
       */

      let googleFontList = await getAllVariableFonts(apiKey, 'woff2', apiUrlStatic);

      // generate autofill
      populateDatalist(datalistFonts, googleFontList)


      // show first
      let item = googleFontList.filter(item => item.family === 'Open Sans')[0];
      googleFontUrl = await getGoogleFontUrl(item);
      showLink(cssUrls, [googleFontUrl])

      //update css
      updatePreview(item, googleFontUrl)


      // filter fonts
      inputFonts.addEventListener('input', async e => {
        let family = e.currentTarget.value;
        let familyQuery = family.replaceAll(' ', '+');
        // filter current family
        let item = googleFontList.filter(item => item.family === family)[0];

        // update links
        googleFontUrl = await getGoogleFontUrl(item);
        showLink(cssUrls, [googleFontUrl])
        updatePreview(item, googleFontUrl)
      });


    })
  </script>

More about the differences between API and meta data structure: "List all variable fonts from Google Fonts API?"