Skip to content

Commit 1d1b0be

Browse files
authored
Merge pull request #1092 from googlefonts/align-alternates-before-anchor-prop
[bracket_layers] add align_alternate_layers preflight transformation
2 parents 682ff4b + 065f864 commit 1d1b0be

File tree

7 files changed

+876
-122
lines changed

7 files changed

+876
-122
lines changed

Lib/glyphsLib/builder/bracket_layers.py

Lines changed: 0 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
from collections import defaultdict
22
from functools import partial
33
from typing import Any
4-
import copy
54

65
from fontTools import designspaceLib
76
from fontTools.varLib import FEAVAR_FEATURETAG_LIB_KEY
@@ -23,8 +22,6 @@ def to_designspace_bracket_layers(self):
2322
"Cannot apply bracket layers unless at least one axis is defined."
2423
)
2524

26-
find_component_use(self)
27-
2825
# At this stage we will naively emit a designspace rule for every layer.
2926
bracket_layer_map = defaultdict(partial(defaultdict, list))
3027
rules = []
@@ -170,122 +167,3 @@ def _expand_kerning_to_brackets(
170167
elif second_match:
171168
bracket_kerning[(first, ufo_glyph_name)] = value
172169
ufo_font.kerning.update(bracket_kerning)
173-
174-
175-
def find_component_use(self):
176-
"""If a glyph uses a component which has alternate layers, that
177-
glyph also must have the same alternate layers or else it will not
178-
correctly swap. We copy the layer locations from the component into
179-
the glyph which uses it."""
180-
# First let's put all the layers in a sensible order so we can
181-
# query them efficiently
182-
master_layers = defaultdict(dict)
183-
alternate_layers = defaultdict(lambda: defaultdict(list))
184-
master_ids = set(master.id for master in self.font.masters)
185-
186-
for glyph in self.font.glyphs:
187-
for layer in glyph.layers:
188-
if layer.layerId in master_ids:
189-
master_layers[layer.layerId][glyph.name] = layer
190-
elif layer.associatedMasterId in master_ids:
191-
alternate_layers[layer.associatedMasterId][glyph.name].append(layer)
192-
193-
# Now let's find those which have a problem: they use components,
194-
# the components have some alternate layers, but the layer doesn't
195-
# have the same.
196-
# Because of the possibility of deeply nested components, we need
197-
# to keep doing this, bubbling up fixes until there's nothing left
198-
# to do.
199-
while True:
200-
problematic_glyphs = defaultdict(set)
201-
for master, layers in master_layers.items():
202-
for glyph_name, layer in layers.items():
203-
my_bracket_layers = [
204-
layer._bracket_info(self._designspace.axes)
205-
for layer in alternate_layers[master][glyph_name]
206-
]
207-
for comp in layer.components:
208-
# Check our alternate layer set-up agrees with theirs
209-
components_bracket_layers = [
210-
layer._bracket_info(self._designspace.axes)
211-
for layer in alternate_layers[master][comp.name]
212-
]
213-
if my_bracket_layers != components_bracket_layers:
214-
# Find what we need to add, and make them hashable
215-
they_have = set(
216-
tuple(x.items())
217-
for x in components_bracket_layers
218-
if x.items()
219-
)
220-
i_have = set(
221-
tuple(x.items()) for x in my_bracket_layers if x.items()
222-
)
223-
needed = they_have - i_have
224-
if needed:
225-
problematic_glyphs[(glyph_name, master)] |= needed
226-
227-
if not problematic_glyphs:
228-
break
229-
230-
# And now, fix the problem.
231-
for (glyph_name, master), needed_brackets in problematic_glyphs.items():
232-
my_bracket_layers = [
233-
layer._bracket_info(self._designspace.axes)
234-
for layer in alternate_layers[master][glyph_name]
235-
]
236-
if my_bracket_layers:
237-
# We have some bracket layers, but they're not the ones we
238-
# expect. Do the wrong thing, because doing the right thing
239-
# requires major investment.
240-
master_name = self.font.masters[master].name
241-
self.logger.warning(
242-
f"Glyph {glyph_name} in master {master_name} has different "
243-
"alternate layers to components that it uses. We don't "
244-
"currently support this case, so some alternate layers will "
245-
"not be applied. Consider fixing the source instead."
246-
)
247-
# Just copy the master layer for each thing we need.
248-
for box in needed_brackets:
249-
new_layer = synthesize_bracket_layer(
250-
master_layers[master][glyph_name], dict(box), self._designspace.axes
251-
)
252-
self.font.glyphs[glyph_name].layers.append(new_layer)
253-
self.bracket_layers.append(new_layer)
254-
alternate_layers[master][glyph_name].append(new_layer)
255-
256-
# any components have now just been appended to the end; let's sort bracket
257-
# layers by glyph order to maintain a consistent, understandable order.
258-
glyphOrder = {g.name: i for (i, g) in enumerate(self.font.glyphs)}
259-
self.bracket_layers.sort(key=lambda layer: glyphOrder.get(layer.parent.name))
260-
261-
262-
def synthesize_bracket_layer(old_layer, box, axes):
263-
new_layer = copy.copy(old_layer) # We don't need a deep copy of everything
264-
new_layer.layerId = ""
265-
new_layer.associatedMasterId = old_layer.layerId
266-
267-
if new_layer.parent.parent.format_version == 2:
268-
axis, (bottom, top) = next(iter(box.items()))
269-
designspace_min, designspace_max = util.designspace_min_max(axes[0])
270-
if designspace_min == bottom:
271-
new_layer.name = old_layer.name + f" ]{top}]"
272-
else:
273-
new_layer.name = old_layer.name + f"[{bottom}]"
274-
else:
275-
new_layer.attributes = dict(
276-
new_layer.attributes
277-
) # But we do need our own version of this
278-
new_layer.attributes["axisRules"] = []
279-
for axis in axes:
280-
if axis.tag in box:
281-
new_layer.attributes["axisRules"].append(
282-
{
283-
"min": box[axis.tag][0],
284-
"max": box[axis.tag][1],
285-
}
286-
)
287-
else:
288-
new_layer.attributes["axisRules"].append({})
289-
290-
assert new_layer._bracket_info(axes) == box
291-
return new_layer

Lib/glyphsLib/builder/transformations/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
from types import MappingProxyType
22
from typing import NamedTuple
33

4+
from .align_alternate_layers import align_alternate_layers
45
from .propagate_anchors import propagate_all_anchors
56

67
TRANSFORMATIONS = [
8+
align_alternate_layers,
79
propagate_all_anchors,
810
]
911

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import copy
2+
import logging
3+
from collections import defaultdict
4+
5+
6+
logger = logging.getLogger(__name__)
7+
8+
9+
def align_alternate_layers(font, glyph_data=None):
10+
"""Ensure composites have the same alternate layers as their components.
11+
12+
If a glyph uses a component which has alternate (aka 'bracket') layers, that
13+
glyph also must have the same alternate layers or else it will not correctly swap
14+
when these are converted GSUB FeatureVariations.
15+
We copy the layer locations from the component into the glyph which uses it.
16+
"""
17+
# First let's put all the layers in a sensible order so we can
18+
# query them efficiently
19+
master_layers = defaultdict(dict)
20+
alternate_layers = defaultdict(lambda: defaultdict(list))
21+
master_ids = set(master.id for master in font.masters)
22+
23+
for glyph in font.glyphs:
24+
if not glyph.export:
25+
continue
26+
for layer in glyph.layers:
27+
if layer.layerId in master_ids:
28+
master_layers[layer.layerId][glyph.name] = layer
29+
elif layer.associatedMasterId in master_ids and layer._is_bracket_layer():
30+
alternate_layers[layer.associatedMasterId][glyph.name].append(layer)
31+
32+
# Now let's find those which have a problem: they use components,
33+
# the components have some alternate layers, but the layer doesn't
34+
# have the same.
35+
# Because of the possibility of deeply nested components, we need
36+
# to keep doing this, bubbling up fixes until there's nothing left
37+
# to do.
38+
while True:
39+
problematic_glyphs = defaultdict(set)
40+
for master, layers in master_layers.items():
41+
for glyph_name, layer in layers.items():
42+
my_bracket_layers = {
43+
tuple(layer._bracket_axis_rules())
44+
for layer in alternate_layers[master][glyph_name]
45+
}
46+
for comp in layer.components:
47+
# Check our alternate layer set-up agrees with theirs
48+
components_bracket_layers = {
49+
tuple(layer._bracket_axis_rules())
50+
for layer in alternate_layers[master][comp.name]
51+
}
52+
if my_bracket_layers != components_bracket_layers:
53+
# Find what we need to add, and make them hashable
54+
needed = components_bracket_layers - my_bracket_layers
55+
if needed:
56+
problematic_glyphs[(glyph_name, master)] |= needed
57+
58+
if not problematic_glyphs:
59+
break
60+
61+
# And now, fix the problem.
62+
for (glyph_name, master), needed_brackets in problematic_glyphs.items():
63+
my_bracket_layers = [
64+
tuple(layer._bracket_axis_rules())
65+
for layer in alternate_layers[master][glyph_name]
66+
]
67+
if my_bracket_layers:
68+
# We have some bracket layers, but they're not the ones we
69+
# expect. Do the wrong thing, because doing the right thing
70+
# requires major investment.
71+
master_name = font.masters[master].name
72+
logger.warning(
73+
f"Glyph {glyph_name} in master {master_name} has different "
74+
"alternate layers to components that it uses. We don't "
75+
"currently support this case, so some alternate layers will "
76+
"not be applied. Consider fixing the source instead."
77+
)
78+
# Just copy the master layer for each thing we need.
79+
for axis_rules in needed_brackets:
80+
new_layer = synthesize_bracket_layer(
81+
master_layers[master][glyph_name], axis_rules
82+
)
83+
font.glyphs[glyph_name].layers.append(new_layer)
84+
alternate_layers[master][glyph_name].append(new_layer)
85+
86+
87+
def synthesize_bracket_layer(old_layer, axis_rules):
88+
new_layer = copy.copy(old_layer) # We don't need a deep copy of everything
89+
new_layer.layerId = ""
90+
new_layer.associatedMasterId = old_layer.layerId
91+
92+
if new_layer.parent.parent.format_version == 2:
93+
bottom, top = next(iter(axis_rules))
94+
if bottom is None:
95+
new_layer.name = old_layer.name + f" ]{top}]"
96+
elif top is None:
97+
new_layer.name = old_layer.name + f" [{bottom}]"
98+
else:
99+
raise AssertionError(f"either {bottom} or {top} must be None")
100+
else:
101+
new_layer.attributes = dict(
102+
new_layer.attributes
103+
) # But we do need our own version of this
104+
new_layer.attributes["axisRules"] = [
105+
{"min": bottom, "max": top} for (bottom, top) in axis_rules
106+
]
107+
108+
assert tuple(new_layer._bracket_axis_rules()) == axis_rules
109+
return new_layer
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
from __future__ import annotations
2+
3+
from pathlib import Path
4+
5+
import pytest
6+
7+
from glyphsLib import GSFont
8+
from glyphsLib.builder.transformations.align_alternate_layers import (
9+
align_alternate_layers,
10+
)
11+
12+
13+
DATA = Path(__file__).parent.parent.parent / "data"
14+
15+
16+
@pytest.mark.parametrize(
17+
"test_path",
18+
[
19+
DATA / "AlignAlternateLayers-g2.glyphs",
20+
DATA / "AlignAlternateLayers-g3.glyphs",
21+
],
22+
)
23+
def test_align_alternate_layers(test_path):
24+
font = GSFont(test_path)
25+
# 'Cacute' has 3 master layers and no alternate layers
26+
Cacute = font.glyphs["Cacute"]
27+
assert len(Cacute.layers) == 3
28+
assert all(l._is_master_layer for l in Cacute.layers)
29+
assert not any(l._is_bracket_layer() for l in Cacute.layers)
30+
31+
# it uses a component 'C' which in turn contains 3 additional alternate layers,
32+
# plus 'acutecomb.case' which has none
33+
C = font.glyphs["C"]
34+
assert len([l for l in font.glyphs["C"].layers if l._is_bracket_layer()]) == 3
35+
assert not any(l._is_bracket_layer() for l in font.glyphs["acutecomb.case"].layers)
36+
37+
align_alternate_layers(font)
38+
39+
# we expect 'Cacute' to now have 3 new alternate layers which have the same
40+
# axis coordinates as the ones from 'C'
41+
assert len(Cacute.layers) == 6
42+
assert len([l for l in Cacute.layers if l._is_master_layer]) == 3
43+
assert len([l for l in Cacute.layers if l._is_bracket_layer()]) == 3
44+
assert {
45+
tuple(l._bracket_axis_rules()) for l in C.layers if l._is_bracket_layer()
46+
} == {
47+
tuple(l._bracket_axis_rules()) for l in Cacute.layers if l._is_bracket_layer()
48+
}

tests/builder/transformations/propagate_anchors_test.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import os.path
66
import uuid
77

8+
import pytest
89
from copy import deepcopy
910
from datetime import datetime
1011
from typing import TYPE_CHECKING
@@ -16,6 +17,9 @@
1617
from glyphsLib.types import Point, Transform
1718
from glyphsLib.writer import dumps
1819

20+
from glyphsLib.builder.transformations.align_alternate_layers import (
21+
align_alternate_layers,
22+
)
1923
from glyphsLib.builder.transformations.propagate_anchors import (
2024
compute_max_component_depths,
2125
get_xy_rotation,
@@ -787,3 +791,42 @@ def test_real_files():
787791
assert sorted((a.name, tuple(a.position)) for a in l1.anchors) == sorted(
788792
(a.name, tuple(a.position)) for a in l2.anchors
789793
)
794+
795+
796+
@pytest.mark.parametrize(
797+
"test_file",
798+
[
799+
"AlignAlternateLayers-g2.glyphs",
800+
"AlignAlternateLayers-g3.glyphs",
801+
],
802+
)
803+
def test_propagate_anchors_after_aligining_alternates(test_file):
804+
# https://github.com/googlefonts/glyphsLib/issues/1090
805+
font = GSFont(os.path.join(DATA, test_file))
806+
807+
Cacute = font.glyphs["Cacute"]
808+
assert not any(l._is_bracket_layer() for l in Cacute.layers)
809+
for layer in Cacute.layers:
810+
assert len(layer.anchors) == 0
811+
812+
align_alternate_layers(font)
813+
propagate_all_anchors(font)
814+
815+
# all layers have now 2 anchors, including the new alternates
816+
Cacute = font.glyphs["Cacute"]
817+
alternate_layers = [l for l in Cacute.layers if l._is_bracket_layer()]
818+
assert len(alternate_layers) == 3
819+
for layer in Cacute.layers:
820+
assert len(layer.anchors) == 2
821+
assert [a.name for a in layer.anchors] == ["bottom", "top"]
822+
823+
# the 'bottom' anchor in one of the alternate layers has different
824+
# position than the corresponding anchor in the associated master layer
825+
# (i.e. it was propagated and not simply duplicated)
826+
alt_layer = alternate_layers[0]
827+
alt_bottom = alt_layer.anchors[0]
828+
master_layer = Cacute.layers[alt_layer.associatedMasterId]
829+
master_bottom = master_layer.anchors[0]
830+
assert alt_bottom.name == master_bottom.name == "bottom"
831+
assert tuple(alt_bottom.position) == (329, 0)
832+
assert tuple(master_bottom.position) == (301, 0)

0 commit comments

Comments
 (0)