[varLib] Add support for designspace 5 + STAT generation + tests

This commit is contained in:
Jany Belluz 2022-04-14 15:05:50 +01:00
parent 5a842cc249
commit 2ea5dc3496
3 changed files with 364 additions and 23 deletions

View File

@ -18,6 +18,7 @@ Then you can make a variable-font this way:
API *will* change in near future.
"""
from typing import List
from fontTools.misc.vector import Vector
from fontTools.misc.roundTools import noRound, otRound
from fontTools.misc.textTools import Tag, tostr
@ -33,7 +34,9 @@ from fontTools.varLib.merger import VariationMerger
from fontTools.varLib.mvar import MVAR_ENTRIES
from fontTools.varLib.iup import iup_delta_optimize
from fontTools.varLib.featureVars import addFeatureVariations
from fontTools.designspaceLib import DesignSpaceDocument
from fontTools.designspaceLib import DesignSpaceDocument, InstanceDescriptor
from fontTools.designspaceLib.split import splitInterpolable, splitVariableFonts
from fontTools.varLib.stat import buildVFStatTable
from functools import partial
from collections import OrderedDict, namedtuple
import os.path
@ -53,7 +56,7 @@ FEAVAR_FEATURETAG_LIB_KEY = "com.github.fonttools.varLib.featureVarsFeatureTag"
# Creation routines
#
def _add_fvar(font, axes, instances):
def _add_fvar(font, axes, instances: List[InstanceDescriptor]):
"""
Add 'fvar' table to font.
@ -81,7 +84,8 @@ def _add_fvar(font, axes, instances):
fvar.axes.append(axis)
for instance in instances:
coordinates = instance.location
# Filter out discrete axis locations
coordinates = {name: value for name, value in instance.location.items() if name in axes}
if "en" not in instance.localisedStyleName:
if not instance.styleName:
@ -198,11 +202,10 @@ def _add_avar(font, axes):
return avar
def _add_stat(font, axes):
# for now we just get the axis tags and nameIDs from the fvar,
# so we can reuse the same nameIDs which were defined in there.
# TODO make use of 'axes' once it adds style attributes info:
# https://github.com/LettError/designSpaceDocument/issues/8
def _add_stat(font):
# Note: this function only gets called by old code that calls `build()`
# directly. Newer code that wants to benefit from STAT data from the
# designspace should call `build_many()`
if "STAT" in font:
return
@ -759,7 +762,8 @@ def load_designspace(designspace):
# Check all master and instance locations are valid and fill in defaults
for obj in masters+instances:
obj_name = obj.name or obj.styleName or ''
loc = obj.location
loc = obj.getFullDesignLocation(ds)
obj.designLocation = loc
if loc is None:
raise VarLibValidationError(
f"Source or instance '{obj_name}' has no location."
@ -770,10 +774,6 @@ def load_designspace(designspace):
f"Location axis '{axis_name}' unknown for '{obj_name}'."
)
for axis_name,axis in axes.items():
if axis_name not in loc:
# NOTE: `axis.default` is always user-space, but `obj.location` always design-space.
loc[axis_name] = axis.map_forward(axis.default)
else:
v = axis.map_backward(loc[axis_name])
if not (axis.minimum <= v <= axis.maximum):
raise VarLibValidationError(
@ -785,7 +785,7 @@ def load_designspace(designspace):
# Normalize master locations
internal_master_locs = [o.location for o in masters]
internal_master_locs = [o.getFullDesignLocation(ds) for o in masters]
log.info("Internal master locations:\n%s", pformat(internal_master_locs))
# TODO This mapping should ideally be moved closer to logic in _add_fvar/avar
@ -865,6 +865,38 @@ def set_default_weight_width_slant(font, location):
font["post"].italicAngle = italicAngle
def build_many(designspace: DesignSpaceDocument, master_finder=lambda s:s, exclude=[], optimize=True, skip_vf=lambda vf_name: False):
"""
Build variable fonts from a designspace file, version 5 which can define
several VFs, or version 4 which has implicitly one VF covering the whole doc.
If master_finder is set, it should be a callable that takes master
filename as found in designspace file and map it to master font
binary as to be opened (eg. .ttf or .otf).
skip_vf can be used to skip building some of the variable fonts defined in
the input designspace. It's a predicate that takes as argument the name
of the variable font and returns `bool`.
Always returns a Dict[str, TTFont] keyed by VariableFontDescriptor.name
"""
res = {}
for _location, subDoc in splitInterpolable(designspace):
for name, vfDoc in splitVariableFonts(subDoc):
if skip_vf(name):
log.debug(f"Skipping variable TTF font: {name}")
continue
vf = build(
vfDoc,
master_finder,
exclude=list(exclude) + ["STAT"],
optimize=optimize
)[0]
if "STAT" not in exclude:
buildVFStatTable(vf, designspace, name)
res[name] = vf
return res
def build(designspace, master_finder=lambda s:s, exclude=[], optimize=True):
"""
Build variation font from a designspace file.
@ -898,7 +930,7 @@ def build(designspace, master_finder=lambda s:s, exclude=[], optimize=True):
# TODO append masters as named-instances as well; needs .designspace change.
fvar = _add_fvar(vf, ds.axes, ds.instances)
if 'STAT' not in exclude:
_add_stat(vf, ds.axes)
_add_stat(vf)
if 'avar' not in exclude:
_add_avar(vf, ds.axes)

View File

@ -0,0 +1,142 @@
"""Extra methods for DesignSpaceDocument to generate its STAT table data."""
from __future__ import annotations
from typing import Dict, List, Union
import fontTools.otlLib.builder
from fontTools.designspaceLib import (
AxisLabelDescriptor,
DesignSpaceDocument,
DesignSpaceDocumentError,
LocationLabelDescriptor,
)
from fontTools.designspaceLib.types import Region, getVFUserRegion, locationInRegion
from fontTools.ttLib import TTFont
def buildVFStatTable(ttFont: TTFont, doc: DesignSpaceDocument, vfName: str) -> None:
"""Build the STAT table for the variable font identified by its name in
the given document.
Knowing which variable we're building STAT data for is needed to subset
the STAT locations to only include what the variable font actually ships.
.. versionadded:: 5.0
.. seealso::
- :func:`getStatAxes()`
- :func:`getStatLocations()`
- :func:`fontTools.otlLib.builder.buildStatTable()`
"""
for vf in doc.getVariableFonts():
if vf.name == vfName:
break
else:
raise DesignSpaceDocumentError(
f"Cannot find the variable font by name {vfName}"
)
region = getVFUserRegion(doc, vf)
return fontTools.otlLib.builder.buildStatTable(
ttFont,
getStatAxes(doc, region),
getStatLocations(doc, region),
doc.elidedFallbackName if doc.elidedFallbackName is not None else 2,
)
def getStatAxes(doc: DesignSpaceDocument, userRegion: Region) -> List[Dict]:
"""Return a list of axis dicts suitable for use as the ``axes``
argument to :func:`fontTools.otlLib.builder.buildStatTable()`.
.. versionadded:: 5.0
"""
# First, get the axis labels with explicit ordering
# then append the others in the order they appear.
maxOrdering = max(
(axis.axisOrdering for axis in doc.axes if axis.axisOrdering is not None),
default=-1,
)
axisOrderings = []
for axis in doc.axes:
if axis.axisOrdering is not None:
axisOrderings.append(axis.axisOrdering)
else:
maxOrdering += 1
axisOrderings.append(maxOrdering)
return [
dict(
tag=axis.tag,
name={"en": axis.name, **axis.labelNames},
ordering=ordering,
values=[
_axisLabelToStatLocation(label)
for label in axis.axisLabels
if locationInRegion({axis.name: label.userValue}, userRegion)
],
)
for axis, ordering in zip(doc.axes, axisOrderings)
]
def getStatLocations(doc: DesignSpaceDocument, userRegion: Region) -> List[Dict]:
"""Return a list of location dicts suitable for use as the ``locations``
argument to :func:`fontTools.otlLib.builder.buildStatTable()`.
.. versionadded:: 5.0
"""
axesByName = {axis.name: axis for axis in doc.axes}
return [
dict(
name={"en": label.name, **label.labelNames},
# Location in the designspace is keyed by axis name
# Location in buildStatTable by axis tag
location={
axesByName[name].tag: value
for name, value in label.getFullUserLocation(doc).items()
},
flags=_labelToFlags(label),
)
for label in doc.locationLabels
if locationInRegion(label.getFullUserLocation(doc), userRegion)
]
def _labelToFlags(label: Union[AxisLabelDescriptor, LocationLabelDescriptor]) -> int:
flags = 0
if label.olderSibling:
flags |= 1
if label.elidable:
flags |= 2
return flags
def _axisLabelToStatLocation(
label: AxisLabelDescriptor,
) -> Dict:
label_format = label.getFormat()
name = {"en": label.name, **label.labelNames}
flags = _labelToFlags(label)
if label_format == 1:
return dict(name=name, value=label.userValue, flags=flags)
if label_format == 3:
return dict(
name=name,
value=label.userValue,
linkedValue=label.linkedUserValue,
flags=flags,
)
if label_format == 2:
res = dict(
name=name,
nominalValue=label.userValue,
flags=flags,
)
if label.userMinimum is not None:
res["rangeMinValue"] = label.userMinimum
if label.userMaximum is not None:
res["rangeMaxValue"] = label.userMaximum
return res
raise NotImplementedError("Unknown STAT label format")

167
Tests/varLib/stat_test.py Normal file
View File

@ -0,0 +1,167 @@
from pathlib import Path
import pytest
from fontTools.designspaceLib import DesignSpaceDocument
from fontTools.designspaceLib.split import Range
from fontTools.varLib.stat import getStatAxes, getStatLocations
@pytest.fixture
def datadir():
return Path(__file__).parent / "../designspaceLib/data"
def test_getStatAxes(datadir):
doc = DesignSpaceDocument.fromfile(datadir / "test_v5.designspace")
assert getStatAxes(
doc, {"Italic": 0, "width": Range(50, 150), "weight": Range(200, 900)}
) == [
{
"values": [
{
"flags": 0,
"name": {
"de": "Extraleicht",
"en": "Extra Light",
"fr": "Extra léger",
},
"nominalValue": 200.0,
"rangeMaxValue": 250.0,
"rangeMinValue": 200.0,
},
{
"flags": 0,
"name": {"en": "Light"},
"nominalValue": 300.0,
"rangeMaxValue": 350.0,
"rangeMinValue": 250.0,
},
{
"flags": 2,
"name": {"en": "Regular"},
"nominalValue": 400.0,
"rangeMaxValue": 450.0,
"rangeMinValue": 350.0,
},
{
"flags": 0,
"name": {"en": "Semi Bold"},
"nominalValue": 600.0,
"rangeMaxValue": 650.0,
"rangeMinValue": 450.0,
},
{
"flags": 0,
"name": {"en": "Bold"},
"nominalValue": 700.0,
"rangeMaxValue": 850.0,
"rangeMinValue": 650.0,
},
{
"flags": 0,
"name": {"en": "Black"},
"nominalValue": 900.0,
"rangeMaxValue": 900.0,
"rangeMinValue": 850.0,
},
],
"name": {"en": "Wéíght", "fa-IR": "قطر"},
"ordering": 2,
"tag": "wght",
},
{
"values": [
{"flags": 0, "name": {"en": "Condensed"}, "value": 50.0},
{"flags": 3, "name": {"en": "Normal"}, "value": 100.0},
{"flags": 0, "name": {"en": "Wide"}, "value": 125.0},
{
"flags": 0,
"name": {"en": "Extra Wide"},
"nominalValue": 150.0,
"rangeMinValue": 150.0,
},
],
"name": {"en": "width", "fr": "Chasse"},
"ordering": 1,
"tag": "wdth",
},
{
"values": [
{"flags": 2, "linkedValue": 1.0, "name": {"en": "Roman"}, "value": 0.0},
],
"name": {"en": "Italic"},
"ordering": 3,
"tag": "ital",
},
]
assert getStatAxes(doc, {"Italic": 1, "width": 100, "weight": Range(400, 700)}) == [
{
"values": [
{
"flags": 2,
"name": {"en": "Regular"},
"nominalValue": 400.0,
"rangeMaxValue": 450.0,
"rangeMinValue": 350.0,
},
{
"flags": 0,
"name": {"en": "Semi Bold"},
"nominalValue": 600.0,
"rangeMaxValue": 650.0,
"rangeMinValue": 450.0,
},
{
"flags": 0,
"name": {"en": "Bold"},
"nominalValue": 700.0,
"rangeMaxValue": 850.0,
"rangeMinValue": 650.0,
},
],
"name": {"en": "Wéíght", "fa-IR": "قطر"},
"ordering": 2,
"tag": "wght",
},
{
"values": [
{"flags": 3, "name": {"en": "Normal"}, "value": 100.0},
],
"name": {"en": "width", "fr": "Chasse"},
"ordering": 1,
"tag": "wdth",
},
{
"values": [
{"flags": 0, "name": {"en": "Italic"}, "value": 1.0},
],
"name": {"en": "Italic"},
"ordering": 3,
"tag": "ital",
},
]
def test_getStatLocations(datadir):
doc = DesignSpaceDocument.fromfile(datadir / "test_v5.designspace")
assert getStatLocations(
doc, {"Italic": 0, "width": Range(50, 150), "weight": Range(200, 900)}
) == [
{
"flags": 0,
"location": {"ital": 0.0, "wdth": 50.0, "wght": 300.0},
"name": {"en": "Some Style", "fr": "Un Style"},
},
]
assert getStatLocations(
doc, {"Italic": 1, "width": Range(50, 150), "weight": Range(200, 900)}
) == [
{
"flags": 0,
"location": {"ital": 1.0, "wdth": 100.0, "wght": 700.0},
"name": {"en": "Other"},
},
]