Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Smart components #822

Merged
merged 15 commits into from
Oct 23, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion Lib/glyphsLib/builder/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from glyphsLib.classes import GSBackgroundLayer
from glyphsLib.types import Transform

from .smart_components import to_ufo_smart_component
from .constants import GLYPHS_PREFIX, COMPONENT_INFO_KEY, SMART_COMPONENT_AXES_LIB_KEY

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -61,7 +62,14 @@ def to_ufo_components(self, ufo_glyph, layer):

pen = ufo_glyph.getPointPen()
for index, component in enumerate(layer.components):
pen.addComponent(component.name, component.transform)
# XXX We may also want to test here if we're compiling a font (and decompose
# if so) or changing the representation format (in which case we leave it
# as a component and save the smart component values).
# See https://github.com/googlefonts/glyphsLib/pull/822
if component.smartComponentValues and component.component.smartComponentAxes:
anthrotype marked this conversation as resolved.
Show resolved Hide resolved
to_ufo_smart_component(self, layer, component, pen)
else:
pen.addComponent(component.name, component.transform)

if not (component.anchor or component.alignment):
continue
Expand Down
138 changes: 138 additions & 0 deletions Lib/glyphsLib/builder/smart_components.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
"""Convert Glyphs smart components.

Smart components (https://glyphsapp.com/learn/smart-components) are a
feature within Glyphs whereby a component can essentially define its
own private designspace - each master of a component glyph can
define its own axes and masters. When the component is used, instead
of simply scaling or transforming the component, the designer can
*interpolate* the component by specifying the location in the private
designspace.

For example, a font might define a ``_part.serif`` glyph component with
"left width" and "right width" axes, and for each master in the font,
define layers for the ``_part.serif`` at some default left width and
right width, one with an extended left width, and one with an extended
right width, and use the "smart components settings" to assign locations
to these additional layers. (Unlike a full interpolation model, the
locations of smart component layers can only be at the axis extremes.)

We handle smart components by decomposing them and then applying a standard
OpenType interpolation model to adjust the node positions.
"""

from enum import IntEnum

# We're going to use pickle/unpickle to copy the node objects because
# it's considerably faster than copy.deepcopy
import pickle
simoncozens marked this conversation as resolved.
Show resolved Hide resolved

from fontTools.varLib.models import VariationModel, normalizeValue
from fontTools.ttLib.tables._g_l_y_f import GlyphCoordinates

from glyphsLib.classes import GSLayer


# smartComponentPoleMapping returns 1 for bottom of axis and 2 for top.
class Pole(IntEnum):
MIN = 1
MAX = 2


# This normalizes the location of a "master" (additional smart component
# layer). Because these are defined to be at the "poles", this is always
# 0.0, 1.0 or -1.0. But working out which it should be is slightly tricky:
# the axes don't define their own defaults, but the location of the
# default layer of the glyph tells you whether the default value of the
# axis is at the top or the bottom.
def normalized_location(layer, base_layer):
loc = {}
for axis_name, current_value in layer.smartComponentPoleMapping.items():
base_value = base_layer.smartComponentPoleMapping[axis_name]
if current_value == base_value:
loc[axis_name] = 0.0
elif base_value == Pole.MIN and current_value == Pole.MAX:
loc[axis_name] = 1.0
elif base_value == Pole.MAX and current_value == Pole.MIN:
loc[axis_name] = -1.0
else:
raise ValueError(
f"Strange axis mapping for axis {axis_name} in smart layer {base_layer}"
)
return loc
anthrotype marked this conversation as resolved.
Show resolved Hide resolved


def variation_model(glyph, smart_layers):
master_locations = [normalized_location(l, smart_layers[0]) for l in smart_layers]
axis_order = [ax.name for ax in glyph.smartComponentAxes]
return VariationModel(master_locations, axisOrder=axis_order, extrapolate=True)


# Two slightly horrible functions for turning a GSLayer into a
# GlyphCoordinates object and back again.
def get_coordinates(layer):
gc = GlyphCoordinates([])
for path in layer.paths:
gc.extend(
GlyphCoordinates([(pt.position.x, pt.position.y) for pt in path.nodes])
)
return gc


def set_coordinates(layer, coords):
counter = 0
for path in layer.paths:
for node in path.nodes:
node.position.x, node.position.y = coords[counter]
counter += 1


def to_ufo_smart_component(self, layer, component, pen):
# Find the GSGlyph that is being used as a component by this GSComponent
root = component.component

masters = [l for l in root.layers if l.smartComponentPoleMapping]
if layer.associatedMasterId:
# Each master in the font can have its own set of smart component
# "master layers", so we need to filter by those smart components
# which are in the same font master as the current one
masters = [
l for l in masters if l.associatedMasterId == layer.associatedMasterId
]
if not masters:
raise ValueError(
"Could not find any masters for the smart component %s used in %s"
% (root.name, layer.name)
)
model = variation_model(root, masters)
simoncozens marked this conversation as resolved.
Show resolved Hide resolved

# Determine the normalized location of the interpolant within the
# mini-designspace, remembering that we have to work out where the
# default value is by looking at the first "master"
axes_tuples = {}
for ax in root.smartComponentAxes:
if masters[0].smartComponentPoleMapping[ax.name] == Pole.MIN:
defaultValue = ax.bottomValue
else:
defaultValue = ax.topValue
axes_tuples[ax.name] = (ax.bottomValue, defaultValue, ax.topValue)
normalized_location = {
name: normalizeValue(value, axes_tuples[name], extrapolate=True)
for name, value in component.smartComponentValues.items()
}
coordinates = [get_coordinates(l) for l in masters]
new_coords = model.interpolateFromMasters(normalized_location, coordinates)

# Decompose by creating a new layer, copying its shapes and applying
# the new coordinates
new_layer = GSLayer()
new_layer._shapes = pickle.loads(pickle.dumps(masters[0]._shapes))
anthrotype marked this conversation as resolved.
Show resolved Hide resolved
set_coordinates(new_layer, new_coords)

# Don't forget that the GSComponent might also be transformed, so
# we need to apply that transformation to the new layer as well
if component.transform:
for p in new_layer.paths:
p.applyTransform(component.transform)

# And we are done
new_layer.drawPoints(pen)
1 change: 1 addition & 0 deletions requirements-dev.in
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ pytest>=2.8
pytest-randomly
pytest-xdist
xmldiff>=2.2
fonttools[ufo,unicode] >= 4.38.0
# extras
ufoNormalizer>=0.3.2
defcon>=0.6.0
Expand Down
18 changes: 5 additions & 13 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#
# This file is autogenerated by pip-compile with python 3.7
# This file is autogenerated by pip-compile with python 3.9
# To update, run:
#
# pip-compile requirements-dev.in
Expand Down Expand Up @@ -32,8 +32,9 @@ flake8==4.0.1
# flake8-bugbear
flake8-bugbear==22.3.23
# via -r requirements-dev.in
fonttools[ufo,unicode]==4.34.4
fonttools[ufo,unicode]==4.38.0
# via
# -r requirements-dev.in
# booleanoperations
# cffsubr
# cu2qu
Expand All @@ -42,12 +43,7 @@ fonttools[ufo,unicode]==4.34.4
fs==2.4.15
# via fonttools
importlib-metadata==4.2.0
# via
# click
# flake8
# pluggy
# pytest
# pytest-randomly
# via pytest-randomly
iniconfig==1.1.1
# via pytest
lxml==4.8.0
Expand Down Expand Up @@ -100,12 +96,8 @@ tomli==2.0.1
# via
# black
# pytest
typed-ast==1.5.3
# via black
typing-extensions==4.2.0
# via
# black
# importlib-metadata
# via black
ufo2ft==2.27.0
# via -r requirements-dev.in
ufonormalizer==0.6.1
Expand Down
6 changes: 2 additions & 4 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#
# This file is autogenerated by pip-compile with python 3.7
# This file is autogenerated by pip-compile with python 3.9
# To update, run:
#
# pip-compile setup.cfg
Expand All @@ -8,7 +8,7 @@ appdirs==1.4.4
# via fs
attrs==21.4.0
# via ufolib2
fonttools[ufo,unicode]==4.34.4
fonttools[ufo,unicode]==4.38.0
# via
# glyphsLib (setup.cfg)
# ufolib2
Expand All @@ -20,8 +20,6 @@ pytz==2022.1
# via fs
six==1.16.0
# via fs
typing-extensions==4.2.0
# via ufolib2
ufolib2==0.13.1
# via glyphsLib (setup.cfg)
unicodedata2==14.0.0
Expand Down
2 changes: 1 addition & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ setup_requires =
setuptools_scm
install_requires =
ufoLib2 >= 0.6.2
fonttools[ufo,unicode] >= 4.33.0
fonttools[ufo,unicode] >= 4.38.0
openstep-plist >= 0.3.0

[options.extras_require]
Expand Down
Loading