From 2ea5dc3496c45cce36cb0fa3377e1d54a5ff1959 Mon Sep 17 00:00:00 2001 From: Jany Belluz Date: Thu, 14 Apr 2022 15:05:50 +0100 Subject: [PATCH] [varLib] Add support for designspace 5 + STAT generation + tests --- Lib/fontTools/varLib/__init__.py | 78 ++++++++++----- Lib/fontTools/varLib/stat.py | 142 ++++++++++++++++++++++++++ Tests/varLib/stat_test.py | 167 +++++++++++++++++++++++++++++++ 3 files changed, 364 insertions(+), 23 deletions(-) create mode 100644 Lib/fontTools/varLib/stat.py create mode 100644 Tests/varLib/stat_test.py diff --git a/Lib/fontTools/varLib/__init__.py b/Lib/fontTools/varLib/__init__.py index 15c2e7007..4029a107e 100644 --- a/Lib/fontTools/varLib/__init__.py +++ b/Lib/fontTools/varLib/__init__.py @@ -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,22 +774,18 @@ 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( - f"Source or instance '{obj_name}' has out-of-range location " - f"for axis '{axis_name}': is mapped to {v} but must be in " - f"mapped range [{axis.minimum}..{axis.maximum}] (NOTE: all " - "values are in user-space)." - ) + v = axis.map_backward(loc[axis_name]) + if not (axis.minimum <= v <= axis.maximum): + raise VarLibValidationError( + f"Source or instance '{obj_name}' has out-of-range location " + f"for axis '{axis_name}': is mapped to {v} but must be in " + f"mapped range [{axis.minimum}..{axis.maximum}] (NOTE: all " + "values are in user-space)." + ) # 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) diff --git a/Lib/fontTools/varLib/stat.py b/Lib/fontTools/varLib/stat.py new file mode 100644 index 000000000..46c9498dc --- /dev/null +++ b/Lib/fontTools/varLib/stat.py @@ -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") diff --git a/Tests/varLib/stat_test.py b/Tests/varLib/stat_test.py new file mode 100644 index 000000000..6aa88a05f --- /dev/null +++ b/Tests/varLib/stat_test.py @@ -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"}, + }, + ]