Skip to content

Commit 346795f

Browse files
authored
Merge pull request #1088 from googlefonts/propagate-bracket-anchors
[propagate_anchors] support propagating anchors from components for bracket/alternate layers
2 parents bf82477 + f81c00c commit 346795f

File tree

5 files changed

+389
-57
lines changed

5 files changed

+389
-57
lines changed

Lib/glyphsLib/builder/transformations/propagate_anchors.py

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -105,9 +105,8 @@ def _interesting_layers(glyph):
105105
return (
106106
l
107107
for l in glyph.layers
108-
if l._is_master_layer
108+
if l._is_master_layer or l._is_bracket_layer()
109109
# or l._is_brace_layer
110-
# or l._is_bracket_layer
111110
# etc.
112111
)
113112

@@ -385,17 +384,48 @@ def get_component_layer_anchors(
385384
glyphs: dict[str, GSGlyph],
386385
anchors: dict[str, dict[str, list[GSAnchor]]],
387386
) -> list[GSAnchor] | None:
387+
if component.name not in anchors:
388+
return None # nothing to propagate
389+
388390
glyph = glyphs.get(component.name)
389391
if glyph is None:
390-
return None
392+
return None # invalid component reference, skip
393+
391394
# in Glyphs.app, the `componentLayer` property would synthesize a layer
392395
# if it is missing. glyphsLib does not have that yet, so for now we
393-
# only support the corresponding 'master' layer of a component's base glyph.
396+
# only support the corresponding 'master' or alternate ('bracket') layers
397+
# of a component's base glyph.
398+
394399
layer_anchors = None
400+
401+
# whether the parent layer where the component is defined is a 'master' layer
402+
# and/or a 'bracket' or alternate layer (masters can have bracket layers too but
403+
# glyphsLib doesn't support that yet).
404+
parent_is_master = layer._is_master_layer
405+
parent_is_bracket = layer._is_bracket_layer()
406+
parent_axis_rules = (
407+
[] if not parent_is_bracket else list(layer._bracket_axis_rules())
408+
)
409+
410+
# we support propagating anchors from the component base glyph's 'master' layer
411+
# (with same layerId), or from a 'bracket' alternate layer with matching axis
412+
# rules and the same associated master; we try the latter first
395413
for comp_layer in _interesting_layers(glyph):
396-
if comp_layer.layerId == layer.layerId and component.name in anchors:
414+
if (
415+
parent_is_bracket
416+
and comp_layer._is_bracket_layer()
417+
and comp_layer.associatedMasterId == layer.associatedMasterId
418+
and (list(comp_layer._bracket_axis_rules()) == parent_axis_rules)
419+
) or (parent_is_master and comp_layer.layerId == layer.layerId):
397420
layer_anchors = anchors[component.name][comp_layer.layerId]
398421
break
422+
423+
# else we fall back to the associated master layer; this is guaranteed to exist
424+
# since all glyphs must at least define one layer per master; if this raised
425+
# KeyError, the font is broken
426+
if layer_anchors is None:
427+
layer_anchors = anchors[component.name][layer.associatedMasterId]
428+
399429
if layer_anchors is not None:
400430
# return a copy as they may be modified in place
401431
layer_anchors = [

Lib/glyphsLib/classes.py

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3947,40 +3947,42 @@ def _is_bracket_layer(self):
39473947
return "axisRules" in self.attributes # Glyphs 3
39483948
return re.match(self.BRACKET_LAYER_RE, self.name) # Glyphs 2
39493949

3950-
def _bracket_info(self, axes):
3951-
# Returns a region expressed as a {axis_tag: (min, max)} box
3952-
# (dictionary), once the axes have been computed
3950+
def _bracket_axis_rules(self):
39533951
if not self._is_bracket_layer():
3954-
return {}
3955-
3952+
return
39563953
if self.parent.parent.format_version > 2:
39573954
# Glyphs 3
3958-
info = {}
3959-
for axis, rule in zip(axes, self.attributes["axisRules"]):
3960-
if "min" not in rule and "max" not in rule:
3961-
continue
3962-
# Rules are expressed in designspace coordinates,
3963-
# so map appropriately.
3964-
designspace_min, designspace_max = designspace_min_max(axis)
3965-
axis_min = rule.get("min", designspace_min)
3966-
axis_max = rule.get("max", designspace_max)
3955+
for rule in self.attributes["axisRules"]:
3956+
axis_min = rule.get("min")
3957+
axis_max = rule.get("max")
39673958
if isinstance(axis_min, str):
39683959
axis_min = float(axis_min)
39693960
if isinstance(axis_max, str):
39703961
axis_max = float(axis_max)
3971-
info[axis.tag] = (axis_min, axis_max)
3972-
return info
3962+
yield (axis_min, axis_max)
3963+
return
39733964

39743965
# Glyphs 2
39753966
m = re.match(self.BRACKET_LAYER_RE, self.name)
3976-
axis = axes[0] # For glyphs 2
3977-
designspace_min, designspace_max = designspace_min_max(axis)
39783967
reverse = m.group("first_bracket") == "]"
39793968
bracket_crossover = int(m.group("value"))
39803969
if reverse:
3981-
return {axis.tag: (designspace_min, bracket_crossover)}
3970+
yield (None, bracket_crossover)
39823971
else:
3983-
return {axis.tag: (bracket_crossover, designspace_max)}
3972+
yield (bracket_crossover, None)
3973+
3974+
def _bracket_info(self, axes):
3975+
# Returns a region expressed as a {axis_tag: (min, max)} box
3976+
# (dictionary), once the axes have been computed
3977+
info = {}
3978+
for axis, (axis_min, axis_max) in zip(axes, self._bracket_axis_rules()):
3979+
# Rules are expressed in designspace coordinates,
3980+
# so map appropriately.
3981+
designspace_min, designspace_max = designspace_min_max(axis)
3982+
axis_min = axis_min if axis_min is not None else designspace_min
3983+
axis_max = axis_max if axis_max is not None else designspace_max
3984+
info[axis.tag] = (axis_min, axis_max)
3985+
return info
39843986

39853987
def _is_brace_layer(self):
39863988
if self.parent.parent.format_version > 2:

tests/builder/transformations/propagate_anchors_test.py

Lines changed: 82 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,19 @@ class GlyphSetBuilder:
3939
glyphs: dict[str, GSGlyph]
4040

4141
def __init__(self):
42-
self.glyphs = {}
42+
self.font = GSFont()
43+
self.font.format_version = 3
4344

4445
def build(self) -> dict[str, GSGlyph]:
45-
return self.glyphs
46+
return {g.name: g for g in self.font.glyphs}
4647

4748
def add_glyph(self, name: str, build_fn: Callable[["GlyphBuilder"], None]) -> Self:
4849
glyph = GlyphBuilder(name)
4950
build_fn(glyph)
50-
self.glyphs[name] = glyph.build()
51+
# this inserts the glyph in the font and sets the glyph.parent attribute to it;
52+
# GSLayer._is_bracket_layer()/_bracket_axis_rules() require this to check the
53+
# glyph.parent.format_version to determine where to look for the axis rules
54+
self.font.glyphs.append(glyph.build())
5155
return self
5256

5357

@@ -60,6 +64,7 @@ def __init__(self, name: str):
6064
glyph.category = info.category
6165
glyph.subCategory = info.subCategory
6266
self.num_masters = 0
67+
self.num_alternates = 0
6368
self.add_master_layer()
6469

6570
def build(self) -> GSGlyph:
@@ -71,6 +76,7 @@ def add_master_layer(self) -> Self:
7176
f"master-{self.num_masters}"
7277
)
7378
self.num_masters += 1
79+
self.num_alternates = 0
7480
self.glyph.layers.append(layer)
7581
self.current_layer = layer
7682
return self
@@ -85,6 +91,20 @@ def add_backup_layer(self, associated_master_idx=0):
8591
self.current_layer = layer
8692
return self
8793

94+
def add_bracket_layer(self, axis_rules):
95+
assert self.current_layer._is_master_layer
96+
associated_master_id = self.current_layer.layerId
97+
master_layer = self.glyph.layers[associated_master_id]
98+
layer = GSLayer()
99+
layer.name = f"{master_layer.name} bracket-{self.num_alternates}"
100+
layer.layerId = str(uuid.uuid4()).upper()
101+
layer.associatedMasterId = associated_master_id
102+
layer.attributes["axisRules"] = axis_rules
103+
self.num_alternates += 1
104+
self.glyph.layers.append(layer)
105+
self.current_layer = layer
106+
return self
107+
88108
def set_category(self, category: str) -> Self:
89109
self.glyph.category = category
90110
return self
@@ -362,31 +382,37 @@ def test_digraphs_arent_ligatures():
362382

363383

364384
def test_propagate_across_layers(caplog):
365-
# derived from the observed behaviour of glyphs 3.2.2 (3259)
385+
# derived from the observed behaviour of glyphs 3.4 (3414)
366386
glyphs = (
367387
GlyphSetBuilder()
368388
.add_glyph(
369389
"A",
370390
lambda glyph: (
371-
glyph.add_anchor("bottom", (290, 10))
372-
.add_anchor("ogonek", (490, 3))
373-
.add_anchor("top", (290, 690))
391+
glyph.add_anchor("bottom", (206, 16))
392+
.add_anchor("ogonek", (360, 13))
393+
.add_anchor("top", (212, 724))
394+
.add_bracket_layer([{"min": 500}])
395+
.add_anchor("bottom", (206, 16))
396+
.add_anchor("top", (212, 724))
374397
.add_master_layer()
375-
.add_anchor("bottom", (300, 0))
376-
.add_anchor("ogonek", (540, 10))
377-
.add_anchor("top", (300, 700))
398+
.add_anchor("bottom", (278, 12))
399+
.add_anchor("ogonek", (464, 13))
400+
.add_anchor("top", (281, 758))
401+
.add_bracket_layer([{"min": 500}])
402+
.add_anchor("bottom", (278, 12))
403+
.add_anchor("top", (281, 758))
378404
.add_backup_layer()
379405
.add_anchor("top", (290, 690))
380406
),
381407
)
382408
.add_glyph(
383409
"acutecomb",
384410
lambda glyph: (
385-
glyph.add_anchor("_top", (335, 502))
386-
.add_anchor("top", (353, 721))
411+
glyph.add_anchor("_top", (150, 580))
412+
.add_anchor("top", (170, 792))
387413
.add_master_layer()
388-
.add_anchor("_top", (366, 500))
389-
.add_anchor("top", (366, 765))
414+
.add_anchor("_top", (167, 580))
415+
.add_anchor("top", (170, 792))
390416
.add_backup_layer()
391417
.add_anchor("_top", (335, 502))
392418
),
@@ -395,10 +421,16 @@ def test_propagate_across_layers(caplog):
395421
"Aacute",
396422
lambda glyph: (
397423
glyph.add_component("A", (0, 0))
398-
.add_component("acutecomb", (-45, 188))
424+
.add_component("acutecomb", (62, 144))
425+
.add_bracket_layer([{"min": 500}])
426+
.add_component("A", (20, 0))
427+
.add_component("acutecomb", (82, 144))
399428
.add_master_layer()
400429
.add_component("A", (0, 0))
401-
.add_component("acutecomb", (-66, 200))
430+
.add_component("acutecomb", (114, 178))
431+
.add_bracket_layer([{"min": 500}])
432+
.add_component("A", (30, 0))
433+
.add_component("acutecomb", (144, 178))
402434
.add_backup_layer()
403435
.add_component("A", (0, 0))
404436
.add_component("acutecomb", (-45, 188))
@@ -433,26 +465,45 @@ def test_propagate_across_layers(caplog):
433465

434466
new_glyph = glyphs["Aacute"]
435467
assert_anchors(
436-
new_glyph.layers[0].anchors,
468+
new_glyph.layers["master-0"].anchors,
437469
[
438-
("bottom", (290, 10)),
439-
("ogonek", (490, 3)),
440-
("top", (308, 909)),
470+
("bottom", (206, 16)),
471+
("ogonek", (360, 13)),
472+
("top", (232, 936)),
441473
],
442474
)
443475

444476
assert_anchors(
445-
new_glyph.layers[1].anchors,
477+
new_glyph.layers["master-1"].anchors,
478+
[
479+
("bottom", (278, 12)),
480+
("ogonek", (464, 13)),
481+
("top", (284, 970)),
482+
],
483+
)
484+
485+
# alternate 'bracket' layers should work as well
486+
alternate_layers = [l for l in new_glyph.layers if l._is_bracket_layer()]
487+
assert len(alternate_layers) == 2
488+
assert_anchors(
489+
alternate_layers[0].anchors,
490+
[
491+
("bottom", (226, 16)),
492+
("top", (252, 936)),
493+
],
494+
)
495+
assert_anchors(
496+
alternate_layers[1].anchors,
446497
[
447-
("bottom", (300, 0)),
448-
("ogonek", (540, 10)),
449-
("top", (300, 965)),
498+
("bottom", (308, 12)),
499+
("top", (314, 970)),
450500
],
451501
)
452502

453503
# non-master (e.g. backup) layers are silently skipped
454-
assert not new_glyph.layers[2]._is_master_layer
455-
assert_anchors(new_glyph.layers[2].anchors, [])
504+
assert not new_glyph.layers[-1]._is_master_layer
505+
assert not new_glyph.layers[-1]._is_bracket_layer()
506+
assert_anchors(new_glyph.layers[-1].anchors, [])
456507

457508
assert len(caplog.records) == 0
458509

@@ -730,6 +781,9 @@ def test_real_files():
730781
for g1, g2 in zip(font.glyphs, expected.glyphs):
731782
assert len(g1.layers) == len(g2.layers)
732783
for l1, l2 in zip(g1.layers, g2.layers):
733-
assert [(a.name, tuple(a.position)) for a in l1.anchors] == [
784+
# seems like the latest Glyphs.app insists on sorting anchors alphabetically
785+
# whereas we keep the original order; but what matters is their name and
786+
# position
787+
assert sorted((a.name, tuple(a.position)) for a in l1.anchors) == sorted(
734788
(a.name, tuple(a.position)) for a in l2.anchors
735-
]
789+
)

0 commit comments

Comments
 (0)