[varLib] Add support for designspace 5 + STAT generation + tests
This commit is contained in:
parent
5a842cc249
commit
2ea5dc3496
@ -18,6 +18,7 @@ Then you can make a variable-font this way:
|
|||||||
|
|
||||||
API *will* change in near future.
|
API *will* change in near future.
|
||||||
"""
|
"""
|
||||||
|
from typing import List
|
||||||
from fontTools.misc.vector import Vector
|
from fontTools.misc.vector import Vector
|
||||||
from fontTools.misc.roundTools import noRound, otRound
|
from fontTools.misc.roundTools import noRound, otRound
|
||||||
from fontTools.misc.textTools import Tag, tostr
|
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.mvar import MVAR_ENTRIES
|
||||||
from fontTools.varLib.iup import iup_delta_optimize
|
from fontTools.varLib.iup import iup_delta_optimize
|
||||||
from fontTools.varLib.featureVars import addFeatureVariations
|
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 functools import partial
|
||||||
from collections import OrderedDict, namedtuple
|
from collections import OrderedDict, namedtuple
|
||||||
import os.path
|
import os.path
|
||||||
@ -53,7 +56,7 @@ FEAVAR_FEATURETAG_LIB_KEY = "com.github.fonttools.varLib.featureVarsFeatureTag"
|
|||||||
# Creation routines
|
# Creation routines
|
||||||
#
|
#
|
||||||
|
|
||||||
def _add_fvar(font, axes, instances):
|
def _add_fvar(font, axes, instances: List[InstanceDescriptor]):
|
||||||
"""
|
"""
|
||||||
Add 'fvar' table to font.
|
Add 'fvar' table to font.
|
||||||
|
|
||||||
@ -81,7 +84,8 @@ def _add_fvar(font, axes, instances):
|
|||||||
fvar.axes.append(axis)
|
fvar.axes.append(axis)
|
||||||
|
|
||||||
for instance in instances:
|
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 "en" not in instance.localisedStyleName:
|
||||||
if not instance.styleName:
|
if not instance.styleName:
|
||||||
@ -198,11 +202,10 @@ def _add_avar(font, axes):
|
|||||||
|
|
||||||
return avar
|
return avar
|
||||||
|
|
||||||
def _add_stat(font, axes):
|
def _add_stat(font):
|
||||||
# for now we just get the axis tags and nameIDs from the fvar,
|
# Note: this function only gets called by old code that calls `build()`
|
||||||
# so we can reuse the same nameIDs which were defined in there.
|
# directly. Newer code that wants to benefit from STAT data from the
|
||||||
# TODO make use of 'axes' once it adds style attributes info:
|
# designspace should call `build_many()`
|
||||||
# https://github.com/LettError/designSpaceDocument/issues/8
|
|
||||||
|
|
||||||
if "STAT" in font:
|
if "STAT" in font:
|
||||||
return
|
return
|
||||||
@ -759,7 +762,8 @@ def load_designspace(designspace):
|
|||||||
# Check all master and instance locations are valid and fill in defaults
|
# Check all master and instance locations are valid and fill in defaults
|
||||||
for obj in masters+instances:
|
for obj in masters+instances:
|
||||||
obj_name = obj.name or obj.styleName or ''
|
obj_name = obj.name or obj.styleName or ''
|
||||||
loc = obj.location
|
loc = obj.getFullDesignLocation(ds)
|
||||||
|
obj.designLocation = loc
|
||||||
if loc is None:
|
if loc is None:
|
||||||
raise VarLibValidationError(
|
raise VarLibValidationError(
|
||||||
f"Source or instance '{obj_name}' has no location."
|
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}'."
|
f"Location axis '{axis_name}' unknown for '{obj_name}'."
|
||||||
)
|
)
|
||||||
for axis_name,axis in axes.items():
|
for axis_name,axis in axes.items():
|
||||||
if axis_name not in loc:
|
v = axis.map_backward(loc[axis_name])
|
||||||
# NOTE: `axis.default` is always user-space, but `obj.location` always design-space.
|
if not (axis.minimum <= v <= axis.maximum):
|
||||||
loc[axis_name] = axis.map_forward(axis.default)
|
raise VarLibValidationError(
|
||||||
else:
|
f"Source or instance '{obj_name}' has out-of-range location "
|
||||||
v = axis.map_backward(loc[axis_name])
|
f"for axis '{axis_name}': is mapped to {v} but must be in "
|
||||||
if not (axis.minimum <= v <= axis.maximum):
|
f"mapped range [{axis.minimum}..{axis.maximum}] (NOTE: all "
|
||||||
raise VarLibValidationError(
|
"values are in user-space)."
|
||||||
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
|
# 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))
|
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
|
# 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
|
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):
|
def build(designspace, master_finder=lambda s:s, exclude=[], optimize=True):
|
||||||
"""
|
"""
|
||||||
Build variation font from a designspace file.
|
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.
|
# TODO append masters as named-instances as well; needs .designspace change.
|
||||||
fvar = _add_fvar(vf, ds.axes, ds.instances)
|
fvar = _add_fvar(vf, ds.axes, ds.instances)
|
||||||
if 'STAT' not in exclude:
|
if 'STAT' not in exclude:
|
||||||
_add_stat(vf, ds.axes)
|
_add_stat(vf)
|
||||||
if 'avar' not in exclude:
|
if 'avar' not in exclude:
|
||||||
_add_avar(vf, ds.axes)
|
_add_avar(vf, ds.axes)
|
||||||
|
|
||||||
|
142
Lib/fontTools/varLib/stat.py
Normal file
142
Lib/fontTools/varLib/stat.py
Normal 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
167
Tests/varLib/stat_test.py
Normal 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"},
|
||||||
|
},
|
||||||
|
]
|
Loading…
x
Reference in New Issue
Block a user