Variable Font Hosting, Syntax and why does Google not provide WOFF2 download for self hosting varibale fonts?
Asked Answered
B

1

7

I'm at a loss here. Trying to add a google variable webfont (open sans) to my website.

  1. When selecting the font, google only creates the <link> for static CSS font-faces. WHY? (semicolons, no '300..700')

Use on the web

To embed a font, copy the code into the of your html

[x] <link>    [ ] @import
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">

CSS rules to specify families

font-family: 'Open Sans', sans-serif;
  1. On the entire page there is nowhere a download for the woff2 (which only comes from the API). The D/L button ONLY serves the .ttf. Ironically in the article about self hosting, they use woff2 as example, even if they don't provide it. Also even the official GITHUB page for the font only serves .ttf. WHY?

  2. Ohter sources provide static files in various formats (but I didn't see variable ones there) and ppl in another thread even posted their own tools like:

  1. After a full day, I finally found this. There another (official) tool is mentioned, for converting ttf to woff2, which seems not easily accomplishable for variable fonts. SRSLY? Is this the only way ?? And why is there no documentation whatsoever ?? (Ok Maybe I should grab the woff2 from the API, but I noticed differences across browsers, I think for example Opera gets serves only the static type not the variable one.)

  2. The 'good' API serves this. But it only uses format('woff2'):

But I've read, for variable fonts the syntax should be more like this, using format('woff2 supports variations') and format('woff2-variations') and @supports (font-variation-settings: normal). WHY Google doesn't use that syntax? Which is better now?

Google:

/* latin */
@font-face {
  font-family: 'Open Sans';
  font-style: normal;
  font-weight: 300 800;
  font-stretch: 100%;
  font-display: swap;
  src: url(https://fonts.gstatic.com/s/opensans/v34/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS-muw.woff2) format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

How it supposedly should be done:

@font-face {
    font-family: Asap;
    src: url('/fonts/Asap-VariableFont_wght.woff2') format('woff2 supports variations'),
         url('/fonts/Asap-VariableFont_wght.woff2') format('woff2-variations');
    font-weight: 400 700;
    font-display: swap;
    font-style: normal;
}

Side note: From the google page I needed to manually change

    https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,300..0,800;1,300..1,800&display=swap

to

https://fonts.googleapis.com/css2?family=Open+Sans:[email protected]&display=swap

to even get the variable font.

Braggart answered 6/9, 2022 at 16:19 Comment(1)
Google Fonts has a presence on github. Perhaps their issues tracker is a more appropriate place to ask why.Sheley
S
1

Update 2024: google fonts UI finally offers a variable font CSS URL

Unfortunately you still can't download woff2 files directly.

Format identifiers like format('woff2-variations') date back to the days when variable font support was rather experimental – so they shouldn't be needed by any decently "modern" browser. However, there might be exceptions like very old browser versions – see caniuse report.

Download fonts via JS fetch

Since google-webfont-helper still doesn't support variable fonts I came up with a custom font loader.

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;
}

async 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
  let im = `@impo` + `rt`;
  gfCss.textContent = `
  ${im} '${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);
}

/**
 * fetch
 */

async function fetchFontsFromCssAndZip(css) {
  // find subset identifiers by comments
  let regexComments = /\/\*\s*([^*]*(?:\*(?!\/)[^*]*)*)\*\//g;
  let subsets = css.match(regexComments).map((sub) => {
    return sub.replace(/(\/\*|\*\/)/g, "").trim();
  });

  //create and parse temporary stylesheet object
  let cssSheet = new CSSStyleSheet();
  cssSheet.replaceSync(css);

  // filter font-face rules
  let rules = [...cssSheet.cssRules].filter((rule) => {
    return rule.type === 5;
  });

  // sanitize font-family name
  let fontFamily = rules[0].style
    .getPropertyValue("font-family")
    .replaceAll('"', "");
  let fontFamilyFilename = fontFamily.replaceAll(" ", "-");

  // create zip object
  let zip = new JSZip();

  // loop through all rules/fonts
  for (let i = 0; i < rules.length; i++) {
    // get properties
    let fontWeight = rules[i].style.getPropertyValue("font-weight");
    let fontStyle = rules[i].style.getPropertyValue("font-style");
    let fontStretch = rules[i].style.getPropertyValue("font-stretch");
    fontStretch = fontStretch === "normal" ? "" : fontStretch;
    let src = rules[i].style.getPropertyValue("src");
    src = src.match(/\(([^)]+)\)/)[1].replaceAll('"', "");

    //replace cryptic file names with readable local names
    let fontName = [fontFamilyFilename, subsets[i], fontWeight, fontStyle, fontStretch]
      .filter(Boolean)
      .join("_") + ".woff2";
    css = css.replaceAll(src, `"${fontFamilyFilename}/${fontName}"`);

    // add data to zip
    let fontData = await (await fetch(src)).arrayBuffer();
    zip.file(`${fontFamilyFilename}/${fontName}`, fontData, {
      type: "uint8array"
    });
  }

  // add simple example HTML
  let htmlDoc = `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
  <link rel="stylesheet" href="${fontFamilyFilename}.css">
  <body style="font-family:'${fontFamily}\'">
  <h1>Sample font</h1>
  <p>One morning, when <em>Gregor Samsa</em> woke from <strong>troubled dreams</strong>, he found himself transformed in his bed into a horrible vermin.</p>
  <p>He lay on his armour-like back, and if he lifted his head a little he could see his brown belly, slightly domed and divided by arches into stiff sections. The bedding was hardly able to cover it and seemed ready to slide off any moment.</p>
  </body></html>`;
  zip.file("index.html", htmlDoc);

  // add css
  fontCss.value = css;
  zip.file(`${fontFamilyFilename}.css`, css);

  // create object url
  let blob = await zip.generateAsync({
    type: "blob"
  });

  blob.name = fontFamilyFilename + ".zip";
  return blob;
}
:root {
  --loadingImg: url("data:image/svg+xml,<svg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'><path d='M12 1A11 11 0 1 0 23 12 11 11 0 0 0 12 1Zm0 19a8 8 0 1 1 8-8A8 8 0 0 1 12 20Z' opacity='.25'/><path d='M10.14 1.16a11 11 0 0 0-9 8.92A1.59 1.59 0 0 0 2.46 12 1.52 1.52 0 0 0 4.11 10.7a8 8 0 0 1 6.66-6.61A1.42 1.42 0 0 0 12 2.69h0A1.57 1.57 0 0 0 10.14 1.16Z'><animateTransform attributeName='transform' type='rotate' dur='0.75s' values='0 12 12;360 12 12' repeatCount='indefinite'/></path></svg>")
}

body {
  font-family: sans-serif
}

legend {
  font-weight: bold;
}

fieldset {
  margin-bottom: 1em;
}

fieldset input,
fieldset textarea {
  border: none
}

input[type="text"] {
  width: 100%;
  display: block;
  margin-bottom: 1em;
}

#inputFonts,
.inputUrl,
input[type="search"] {
  font-size: 2em;
  margin-bottom: 1em;
  border: 1px solid #000;
  border-radius: 0.3em;
}

input[type="checkbox"] {
  width: auto;
  display: inline-block;
  margin-bottom: 1em;
}

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

.btn-default {
  text-decoration: none;
  border: 1px solid #000;
  background: #ccc;
  color: #000;
  font-weight: bold;
  padding: 0.3em;
  font-family: inherit;
  font-size: 1em;
  margin-right: 0.3em;
  cursor: pointer;
}

.inactive {
  pointer-events: none;
  opacity: 0;
}

.btn-load .icn-loading {
  opacity: 0;
}

.btn-load.loading .icn-loading {
  opacity: 1;
}


/*
.btn-load.active
.icn-loading{
  width:0px;
}
*/

.icn-loading {
  transition: 0.3s;
  transform: translateY(0.15em);
  display: inline-block;
  position: relative;
  overflow: hidden;
  width: 1em;
  height: 1em;
  background-image: var(--loadingImg);
  background-repeat: no-repeat;
  background-position: 0%;
  color: transparent;
  border-color: transparent;
}
<h1>Fetch variable fonts from google</h1>
<style id="gfCss"></style>

<p><button class="btn-default btn-fetch" id="fetchData">Fetch font files</button><a class="btn-default btn-load inactive" id="btnDownload" href="#" download="fontface.css">Download fontkit <span class="icn-loading"></span></a> </p>

<fieldset>
  <legend>Search font by name</legend>
  <input type="text" list="datalistFonts" id="inputFonts" placeholder="Enter font-family name">

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

  <label for="">Or enter CSS URL</label>
  <p><input type="text" class="inputUrl" id="inputUrl" value="https://fonts.googleapis.com/css2?family=Source+Sans+3:ital,wght@0,200..900;1,200..900"></p>
</fieldset>

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

<fieldset>
  <legend>New Css</legend>
  <textarea id="fontCss"></textarea>
</fieldset>

<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/jszip.js"></script>

<script>
  window.addEventListener('DOMContentLoaded', async(e) => {

    // enter you own API key
    let apiKey = '';

    // static copy of developer API response
    let apiUrlStatic = "https://cdn.jsdelivr.net/gh/herrstrietzel/fonthelpers@main/json/gfontsAPI.json";
    // inputs
    const inputUrl = document.getElementById('inputUrl');
    const btnDownload = document.getElementById('btnDownload');
    
    // init example
    (async() => {
      /**
       * 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];
      inputFonts.value = item.family;
      //console.log(item);
      googleFontUrl = await getGoogleFontUrl(item);
      inputUrl.value = googleFontUrl;
      //update css
      updatePreview(item, googleFontUrl)
      // filter fonts
      inputFonts.addEventListener('change', 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);
        inputUrl.value = googleFontUrl;
        //showLink(cssUrls, [googleFontUrl])
        updatePreview(item, googleFontUrl)
      });
      //updateGoogleCssUrl();
    })();

    inputUrl.addEventListener("change", async(e) => {
      updateGoogleCssUrl()
    });

    fetchData.addEventListener("click", async(e) => {
      btnDownload.classList.remove('inactive');
      btnDownload.classList.add('active');
      btnDownload.classList.add('loading');
      updateGoogleCssUrl()
    });
    // fetch 
    async function updateGoogleCssUrl() {
      // fetch css content as text
      let url = inputUrl.value;
      let css = await (await fetch(url)).text();

      // fetch font files and zip
      let blob = await fetchFontsFromCssAndZip(css);
      let objectUrl = URL.createObjectURL(blob);
      // update download link
      btnDownload.href = objectUrl;
      btnDownload.download = blob.name;
      btnDownload.classList.replace('inactive', 'active');
      btnDownload.classList.remove('loading');
    }
  })
</script>

The download functionality doesn't work in SO snippets. See working codepen example

How it works

  1. fetch font list via dev API or static copy of the font list JSON response
  2. filter list by font-family name
  3. generate query URL based on available styles and axes e.g
https://fonts.googleapis.com/css2?family=Open+Sans:ital,wdth,wght@0,75..100,300..800;1,75..100,300..800&display=swap
  1. fetch and parse CSS containing all @font-face rules
  2. replace remote font URLs with new ones for local usage
  3. fetch font-files, create arrayBuffer() and add data to JSZIP objects

Browser sniffing

Unfortunately, google deploys uses agent detection and may fail to deliver Variable Fonts to some browser like Opera (despite supporting variable fonts)

Synapse answered 26/3 at 20:24 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.