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
})
})
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).
// 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;
// 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>
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>