From e8590bd1bb566659c2f7df76118bd737d4dfef15 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Tue, 20 Aug 2024 13:04:48 +0100 Subject: [PATCH 1/5] propagate_anchors_test: test that backup layers get skipped --- .../transformations/propagate_anchors_test.py | 46 +++++++++++++++---- 1 file changed, 36 insertions(+), 10 deletions(-) diff --git a/tests/builder/transformations/propagate_anchors_test.py b/tests/builder/transformations/propagate_anchors_test.py index a4c90fabf..9d270a452 100644 --- a/tests/builder/transformations/propagate_anchors_test.py +++ b/tests/builder/transformations/propagate_anchors_test.py @@ -2,8 +2,10 @@ import math import os.path +import uuid from copy import deepcopy +from datetime import datetime from typing import TYPE_CHECKING from fontTools.misc.transform import Transform as Affine @@ -55,17 +57,30 @@ def __init__(self, name: str): glyph.unicode = info.unicode glyph.category = info.category glyph.subCategory = info.subCategory - self.add_layer() + self.num_masters = 0 + self.add_master_layer() def build(self) -> GSGlyph: return self.glyph - def add_layer(self) -> Self: + def add_master_layer(self) -> Self: layer = GSLayer() layer.name = layer.layerId = layer.associatedMasterId = ( - f"layer-{len(self.glyph.layers)}" + f"master-{self.num_masters}" ) + self.num_masters += 1 self.glyph.layers.append(layer) + self.current_layer = layer + return self + + def add_backup_layer(self, associated_master_idx=0): + layer = GSLayer() + layer.name = datetime.now().isoformat() + layer.layerId = str(uuid.uuid4()).upper() + master_layer = self.glyph.layers[associated_master_idx] + layer.associatedMasterId = master_layer.layerId + self.glyph.layers.append(layer) + self.current_layer = layer return self def set_category(self, category: str) -> Self: @@ -78,12 +93,12 @@ def set_subCategory(self, subCategory: str) -> Self: def add_component(self, name: str, pos: tuple[float, float]) -> Self: component = GSComponent(name, offset=pos) - self.glyph.layers[-1].components.append(component) + self.current_layer.components.append(component) return self def rotate_component(self, degrees: float) -> Self: # Set an explicit translate + rotation for the component - component = self.glyph.layers[-1].components[-1] + component = self.current_layer.components[-1] component.transform = Transform( *Affine(*component.transform).rotate(math.radians(degrees)) ) @@ -91,13 +106,13 @@ def rotate_component(self, degrees: float) -> Self: def add_component_anchor(self, name: str) -> Self: # add an explicit anchor to the last added component - component = self.glyph.layers[-1].components[-1] + component = self.current_layer.components[-1] component.anchor = name return self def add_anchor(self, name: str, pos: tuple[float, float]) -> Self: anchor = GSAnchor(name, Point(*pos)) - self.glyph.layers[-1].anchors.append(anchor) + self.current_layer.anchors.append(anchor) return self @@ -327,10 +342,12 @@ def test_propagate_across_layers(): glyph.add_anchor("bottom", (290, 10)) .add_anchor("ogonek", (490, 3)) .add_anchor("top", (290, 690)) - .add_layer() + .add_master_layer() .add_anchor("bottom", (300, 0)) .add_anchor("ogonek", (540, 10)) .add_anchor("top", (300, 700)) + .add_backup_layer() + .add_anchor("top", (290, 690)) ), ) .add_glyph( @@ -338,9 +355,11 @@ def test_propagate_across_layers(): lambda glyph: ( glyph.add_anchor("_top", (335, 502)) .add_anchor("top", (353, 721)) - .add_layer() + .add_master_layer() .add_anchor("_top", (366, 500)) .add_anchor("top", (366, 765)) + .add_backup_layer() + .add_anchor("_top", (335, 502)) ), ) .add_glyph( @@ -348,9 +367,12 @@ def test_propagate_across_layers(): lambda glyph: ( glyph.add_component("A", (0, 0)) .add_component("acutecomb", (-45, 188)) - .add_layer() + .add_master_layer() .add_component("A", (0, 0)) .add_component("acutecomb", (-66, 200)) + .add_backup_layer() + .add_component("A", (0, 0)) + .add_component("acutecomb", (-45, 188)) ), ) .build() @@ -376,6 +398,10 @@ def test_propagate_across_layers(): ], ) + # non-master (e.g. backup) layers are skipped + assert not new_glyph.layers[2]._is_master_layer + assert_anchors(new_glyph.layers[2].anchors, []) + def test_remove_exit_anchor_on_component(): # derived from the observed behaviour of glyphs 3.2.2 (3259) From 5decc6ccc67ab3d2d5a8124ea84bd93897b82015 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Tue, 20 Aug 2024 13:34:00 +0100 Subject: [PATCH 2/5] propagate_anchors_test: add cyclical component reference in backup layer a not too dissimilar configuration was found in https://github.com/googlefonts/genos --- .../transformations/propagate_anchors_test.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/builder/transformations/propagate_anchors_test.py b/tests/builder/transformations/propagate_anchors_test.py index 9d270a452..bb5d6e989 100644 --- a/tests/builder/transformations/propagate_anchors_test.py +++ b/tests/builder/transformations/propagate_anchors_test.py @@ -375,6 +375,27 @@ def test_propagate_across_layers(): .add_component("acutecomb", (-45, 188)) ), ) + .add_glyph( + "acutecomb.case", + lambda glyph: ( + glyph.add_component("acutecomb", (0, 0)) + .add_master_layer() + .add_component("acutecomb", (0, 0)) + # this backup layer contains a cyclical component reference + # as the component's base glyph in turn points back at self; + # this should not trigger an infinite loop! + .add_backup_layer() + .add_component("acute", (0, 0)) + ), + ) + .add_glyph( + "acute", + lambda glyph: ( + glyph.add_component("acutecomb.case", (0, 0)) + .add_master_layer() + .add_component("acutecomb.case", (0, 0)) + ), + ) .build() ) propagate_all_anchors_impl(glyphs) From 6553a0ffa19fd436edc95a998be5b8f02bd270e5 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 21 Aug 2024 14:44:32 +0100 Subject: [PATCH 3/5] propagate_anchors: explicitly only process the master layers just like fontc effectively does; at least until we implement proper support for dereferencing components from special layers --- .../transformations/propagate_anchors.py | 40 +++++++++++++------ .../transformations/propagate_anchors_test.py | 5 ++- 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/Lib/glyphsLib/builder/transformations/propagate_anchors.py b/Lib/glyphsLib/builder/transformations/propagate_anchors.py index ffc198771..62dd4c80d 100644 --- a/Lib/glyphsLib/builder/transformations/propagate_anchors.py +++ b/Lib/glyphsLib/builder/transformations/propagate_anchors.py @@ -60,10 +60,10 @@ def propagate_all_anchors_impl( # anchors, but we only *set* those anchors on glyphs that have components. # to make this work, we write the anchors to a separate data structure, and # then only update the actual glyphs after we've done all the work. - all_anchors: dict[str, list[list[GSAnchor]]] = {} + all_anchors: dict[str, dict[str, list[GSAnchor]]] = {} for name in todo: glyph = glyphs[name] - for layer in glyph.layers: + for layer in _interesting_layers(glyph): anchors = anchors_traversing_components( glyph, layer, @@ -73,15 +73,14 @@ def propagate_all_anchors_impl( glyph_data, ) maybe_log_new_anchors(anchors, glyph, layer) - all_anchors.setdefault(name, []).append(anchors) + all_anchors.setdefault(name, {})[layer.layerId] = anchors # finally update our glyphs with the new anchors, where appropriate for name, layers in all_anchors.items(): glyph = glyphs[name] if _has_components(glyph): - assert len(layers) == len(glyph.layers) - for i, layer_anchors in enumerate(layers): - glyph.layers[i].anchors = layer_anchors + for layer_id, layer_anchors in layers.items(): + glyph.layers[layer_id].anchors = layer_anchors def maybe_log_new_anchors( @@ -100,8 +99,21 @@ def maybe_log_new_anchors( ) +def _interesting_layers(glyph): + # only master layers are currently supported for anchor propagation: + # https://github.com/googlefonts/glyphsLib/issues/1017 + return ( + l + for l in glyph.layers + if l._is_master_layer + # or l._is_brace_layer + # or l._is_bracket_layer + # etc. + ) + + def _has_components(glyph: GSGlyph) -> bool: - return any(layer.components for layer in glyph.layers if layer._is_master_layer) + return any(layer.components for layer in _interesting_layers(glyph)) def _get_category( @@ -132,7 +144,7 @@ def anchors_traversing_components( glyph: GSGlyph, layer: GSLayer, glyphs: dict[str, GSGlyph], - done_anchors: dict[str, list[list[GSAnchor]]], + done_anchors: dict[str, dict[str, list[GSAnchor]]], base_glyph_counts: dict[(str, str), int], glyph_data: glyphdata.GlyphData | None = None, ) -> list[GSAnchor]: @@ -370,7 +382,7 @@ def get_component_layer_anchors( component: GSComponent, layer: GSLayer, glyphs: dict[str, GSGlyph], - anchors: dict[str, list[list[GSAnchor]]], + anchors: dict[str, dict[str, list[GSAnchor]]], ) -> list[GSAnchor] | None: glyph = glyphs.get(component.name) if glyph is None: @@ -379,12 +391,12 @@ def get_component_layer_anchors( # if it is missing. glyphsLib does not have that yet, so for now we # only support the corresponding 'master' layer of a component's base glyph. layer_anchors = None - for layer_idx, comp_layer in enumerate(glyph.layers): + for comp_layer in _interesting_layers(glyph): if comp_layer.layerId == layer.layerId and component.name in anchors: try: - layer_anchors = anchors[component.name][layer_idx] + layer_anchors = anchors[component.name][comp_layer.layerId] break - except IndexError: + except KeyError: if component.name == layer.parent.name: # cyclic reference? ignore break @@ -419,7 +431,9 @@ def depth_sorted_composite_glyphs(glyphs: dict[str, GSGlyph]) -> list[str]: component_buf.clear() component_buf.extend( comp.name - for comp in chain.from_iterable(l.components for l in next_glyph.layers) + for comp in chain.from_iterable( + l.components for l in _interesting_layers(next_glyph) + ) if comp.name in glyphs # ignore missing components ) if not component_buf: diff --git a/tests/builder/transformations/propagate_anchors_test.py b/tests/builder/transformations/propagate_anchors_test.py index bb5d6e989..3de96f33e 100644 --- a/tests/builder/transformations/propagate_anchors_test.py +++ b/tests/builder/transformations/propagate_anchors_test.py @@ -381,9 +381,10 @@ def test_propagate_across_layers(): glyph.add_component("acutecomb", (0, 0)) .add_master_layer() .add_component("acutecomb", (0, 0)) - # this backup layer contains a cyclical component reference + # this backup layer contains a potentially cyclical reference # as the component's base glyph in turn points back at self; - # this should not trigger an infinite loop! + # this doesn't trigger an infinite loop because backup layers + # are skipped when propagating anchors .add_backup_layer() .add_component("acute", (0, 0)) ), From 6be3684d51e460dfbf9e843601f0d955ac7ef080 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 21 Aug 2024 15:41:23 +0100 Subject: [PATCH 4/5] propagate_anchors: detect component cycles and avoid infinite loop Fixes #1022 --- .../transformations/propagate_anchors.py | 103 +++++++++++------- .../transformations/propagate_anchors_test.py | 76 ++++++++++++- 2 files changed, 135 insertions(+), 44 deletions(-) diff --git a/Lib/glyphsLib/builder/transformations/propagate_anchors.py b/Lib/glyphsLib/builder/transformations/propagate_anchors.py index 62dd4c80d..b4359a978 100644 --- a/Lib/glyphsLib/builder/transformations/propagate_anchors.py +++ b/Lib/glyphsLib/builder/transformations/propagate_anchors.py @@ -14,9 +14,8 @@ from __future__ import annotations import logging -from collections import deque from itertools import chain -from math import atan2, degrees +from math import atan2, degrees, isinf from typing import TYPE_CHECKING from fontTools.misc.transform import Transform @@ -393,15 +392,8 @@ def get_component_layer_anchors( layer_anchors = None for comp_layer in _interesting_layers(glyph): if comp_layer.layerId == layer.layerId and component.name in anchors: - try: - layer_anchors = anchors[component.name][comp_layer.layerId] - break - except KeyError: - if component.name == layer.parent.name: - # cyclic reference? ignore - break - else: - raise + layer_anchors = anchors[component.name][comp_layer.layerId] + break if layer_anchors is not None: # return a copy as they may be modified in place layer_anchors = [ @@ -411,42 +403,71 @@ def get_component_layer_anchors( return layer_anchors -def depth_sorted_composite_glyphs(glyphs: dict[str, GSGlyph]) -> list[str]: - queue = deque() - # map of the maximum component depth of a glyph. +def compute_max_component_depths(glyphs: dict[str, GSGlyph]) -> dict[str, float]: + # Returns a map of the maximum component depth of each glyph. # - a glyph with no components has depth 0, # - a glyph with a component has depth 1, # - a glyph with a component that itself has a component has depth 2, etc + # - a glyph with a cyclical component reference has infinite depth, which is + # technically a source error depths = {} - component_buf = [] - for name, glyph in glyphs.items(): - if _has_components(glyph): - queue.append(glyph) - else: - depths[name] = 0 - - while queue: - next_glyph = queue.popleft() - # put all components from this glyph to our reuseable buffer - component_buf.clear() - component_buf.extend( + + def component_names(glyph): + return { comp.name for comp in chain.from_iterable( - l.components for l in _interesting_layers(next_glyph) + l.components for l in _interesting_layers(glyph) ) if comp.name in glyphs # ignore missing components - ) - if not component_buf: - # all components missing?! this is not actually a composite glyph - depths[next_glyph.name] = 0 - elif all(comp in depths for comp in component_buf): - # increment max depth but only if all components have been seen - depth = max(depths[comp] for comp in component_buf) - depths[next_glyph.name] = depth + 1 - else: - # else push to the back to try again after we've done the rest - # (including the currently missing components) - queue.append(next_glyph) - - by_depth = sorted((depth, name) for name, depth in depths.items()) + } + + # we depth-first traverse the component trees so we can detect cycles as they + # happen, but we do it iteratively with an explicit stack to avoid recursion + for name, glyph in glyphs.items(): + if name in depths: + continue + stack = [(glyph, False)] + # set to track the currently visiting glyphs for cycle detection + visiting = set() + while stack: + glyph, is_backtracking = stack.pop() + if is_backtracking: + # All dependencies have been processed: calculate depth and remove + # from the visiting set + depths[glyph.name] = ( + max((depths[c] for c in component_names(glyph)), default=-1) + 1 + ) + visiting.remove(glyph.name) + else: + if glyph.name in depths: + # Already visited and processed + continue + + if glyph.name in visiting: + # Already visiting? It's a cycle! + logger.warning("Cycle detected in composite glyph '%s'", glyph.name) + depths[glyph.name] = float("inf") + continue + + # Neither visited nor visiting: mark as visiting and re-add to the + # stack so it will get processed _after_ its components + # (is_backtracking == True) + visiting.add(glyph.name) + stack.append((glyph, True)) + # Add all its components (if any) to the stack + for comp_name in component_names(glyph): + if comp_name not in depths: + stack.append((glyphs[comp_name], False)) + assert not visiting + assert len(depths) == len(glyphs) + + return depths + + +def depth_sorted_composite_glyphs(glyphs: dict[str, GSGlyph]) -> list[str]: + depths = compute_max_component_depths(glyphs) + # skip glyphs with infinite depth (cyclic dependencies) + by_depth = sorted( + (depth, name) for name, depth in depths.items() if not isinf(depth) + ) return [name for _, name in by_depth] diff --git a/tests/builder/transformations/propagate_anchors_test.py b/tests/builder/transformations/propagate_anchors_test.py index 3de96f33e..06486a5b8 100644 --- a/tests/builder/transformations/propagate_anchors_test.py +++ b/tests/builder/transformations/propagate_anchors_test.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging import math import os.path import uuid @@ -16,6 +17,7 @@ from glyphsLib.writer import dumps from glyphsLib.builder.transformations.propagate_anchors import ( + compute_max_component_depths, get_xy_rotation, propagate_all_anchors, propagate_all_anchors_impl, @@ -136,9 +138,29 @@ def test_components_by_depth(): ("Aacute", ["A", "acutecomb"]), ("Aacutebreve", ["A", "brevecomb_acutecomb"]), ("AEacutebreve", ["AE", "brevecomb_acutecomb"]), + ("acute", ["acutecomb.case"]), + ("acutecomb.case", ["acutecomb.alt"]), + ("acutecomb.alt", ["acute"]), + ("grave", ["grave"]), ] } + assert compute_max_component_depths(glyphs) == { + "A": 0, + "E": 0, + "acutecomb": 0, + "brevecomb": 0, + "brevecomb_acutecomb": 1, + "AE": 1, + "Aacute": 1, + "Aacutebreve": 2, + "AEacutebreve": 2, + "acute": float("inf"), + "acutecomb.case": float("inf"), + "acutecomb.alt": float("inf"), + "grave": float("inf"), + } + assert depth_sorted_composite_glyphs(glyphs) == [ "A", "E", @@ -149,6 +171,7 @@ def test_components_by_depth(): "brevecomb_acutecomb", "AEacutebreve", "Aacutebreve", + # cyclical composites are skipped ] @@ -332,7 +355,7 @@ def test_digraphs_arent_ligatures(): ) -def test_propagate_across_layers(): +def test_propagate_across_layers(caplog): # derived from the observed behaviour of glyphs 3.2.2 (3259) glyphs = ( GlyphSetBuilder() @@ -399,7 +422,8 @@ def test_propagate_across_layers(): ) .build() ) - propagate_all_anchors_impl(glyphs) + with caplog.at_level(logging.WARNING): + propagate_all_anchors_impl(glyphs) new_glyph = glyphs["Aacute"] assert_anchors( @@ -420,10 +444,56 @@ def test_propagate_across_layers(): ], ) - # non-master (e.g. backup) layers are skipped + # non-master (e.g. backup) layers are silently skipped assert not new_glyph.layers[2]._is_master_layer assert_anchors(new_glyph.layers[2].anchors, []) + assert len(caplog.records) == 0 + + +def test_propagate_across_layers_with_circular_reference(caplog): + glyphs = ( + GlyphSetBuilder() + # acutecomb.alt contains a cyclical reference to itself in its master layer + # test that this doesn't cause an infinite loop + .add_glyph( + "acutecomb.alt", + lambda glyph: ( + glyph.add_component("acutecomb.alt", (0, 0)) + .add_master_layer() + .add_component("acutecomb.alt", (0, 0)) + ), + ) + # gravecomb and grave contain cyclical component references to one another + # in their master layers; test that this doesn't cause an infinite loop either + .add_glyph( + "gravecomb", + lambda glyph: ( + glyph.add_component("grave", (0, 0)) + .add_master_layer() + .add_component("grave", (0, 0)) + ), + ) + .add_glyph( + "grave", + lambda glyph: ( + glyph.add_component("gravecomb", (0, 0)) + .add_master_layer() + .add_component("gravecomb", (0, 0)) + ), + ) + .build() + ) + + with caplog.at_level(logging.WARNING): + propagate_all_anchors_impl(glyphs) + + assert len(caplog.records) == 2 + assert ( + caplog.records[0].message == "Cycle detected in composite glyph 'acutecomb.alt'" + ) + assert caplog.records[1].message == "Cycle detected in composite glyph 'gravecomb'" + def test_remove_exit_anchor_on_component(): # derived from the observed behaviour of glyphs 3.2.2 (3259) From 757841eaa4a4cbc0be2e6c26d92954ed07d43291 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Thu, 22 Aug 2024 15:45:46 +0100 Subject: [PATCH 5/5] use a simpler approach to cycle detection to match googlefonts/fontc#907 https://github.com/googlefonts/fontc/pull/907 --- .../transformations/propagate_anchors.py | 78 +++++++++---------- .../transformations/propagate_anchors_test.py | 6 +- 2 files changed, 39 insertions(+), 45 deletions(-) diff --git a/Lib/glyphsLib/builder/transformations/propagate_anchors.py b/Lib/glyphsLib/builder/transformations/propagate_anchors.py index b4359a978..e4f2d492b 100644 --- a/Lib/glyphsLib/builder/transformations/propagate_anchors.py +++ b/Lib/glyphsLib/builder/transformations/propagate_anchors.py @@ -14,6 +14,7 @@ from __future__ import annotations import logging +from collections import deque from itertools import chain from math import atan2, degrees, isinf from typing import TYPE_CHECKING @@ -404,6 +405,7 @@ def get_component_layer_anchors( def compute_max_component_depths(glyphs: dict[str, GSGlyph]) -> dict[str, float]: + queue = deque() # Returns a map of the maximum component depth of each glyph. # - a glyph with no components has depth 0, # - a glyph with a component has depth 1, @@ -412,53 +414,47 @@ def compute_max_component_depths(glyphs: dict[str, GSGlyph]) -> dict[str, float] # technically a source error depths = {} - def component_names(glyph): - return { + # for cycle detection; anytime a glyph is waiting for components (and so is + # pushed to the back of the queue) we record its name and the length of the queue. + # If we process the same glyph twice without the queue having gotten smaller + # (meaning we have gone through everything in the queue) that means we aren't + # making progress, and have a cycle. + waiting_for_components = {} + + for name, glyph in glyphs.items(): + if _has_components(glyph): + queue.append(glyph) + else: + depths[name] = 0 + + while queue: + next_glyph = queue.popleft() + comp_names = { comp.name for comp in chain.from_iterable( - l.components for l in _interesting_layers(glyph) + l.components for l in _interesting_layers(next_glyph) ) if comp.name in glyphs # ignore missing components } - - # we depth-first traverse the component trees so we can detect cycles as they - # happen, but we do it iteratively with an explicit stack to avoid recursion - for name, glyph in glyphs.items(): - if name in depths: - continue - stack = [(glyph, False)] - # set to track the currently visiting glyphs for cycle detection - visiting = set() - while stack: - glyph, is_backtracking = stack.pop() - if is_backtracking: - # All dependencies have been processed: calculate depth and remove - # from the visiting set - depths[glyph.name] = ( - max((depths[c] for c in component_names(glyph)), default=-1) + 1 - ) - visiting.remove(glyph.name) + if all(comp in depths for comp in comp_names): + depths[next_glyph.name] = ( + max((depths[c] for c in comp_names), default=-1) + 1 + ) + waiting_for_components.pop(next_glyph.name, None) + else: + # else push to the back to try again after we've done the rest + # (including the currently missing components) + last_queue_len = waiting_for_components.get(next_glyph.name) + waiting_for_components[next_glyph.name] = len(queue) + if last_queue_len != len(queue): + logger.debug("glyph '%s' is waiting for components", next_glyph.name) + queue.append(next_glyph) else: - if glyph.name in depths: - # Already visited and processed - continue - - if glyph.name in visiting: - # Already visiting? It's a cycle! - logger.warning("Cycle detected in composite glyph '%s'", glyph.name) - depths[glyph.name] = float("inf") - continue - - # Neither visited nor visiting: mark as visiting and re-add to the - # stack so it will get processed _after_ its components - # (is_backtracking == True) - visiting.add(glyph.name) - stack.append((glyph, True)) - # Add all its components (if any) to the stack - for comp_name in component_names(glyph): - if comp_name not in depths: - stack.append((glyphs[comp_name], False)) - assert not visiting + depths[next_glyph.name] = float("inf") + waiting_for_components.pop(next_glyph.name, None) + logger.warning("glyph '%s' has cyclical components", next_glyph.name) + + assert not waiting_for_components assert len(depths) == len(glyphs) return depths diff --git a/tests/builder/transformations/propagate_anchors_test.py b/tests/builder/transformations/propagate_anchors_test.py index 06486a5b8..c3a1775d2 100644 --- a/tests/builder/transformations/propagate_anchors_test.py +++ b/tests/builder/transformations/propagate_anchors_test.py @@ -489,10 +489,8 @@ def test_propagate_across_layers_with_circular_reference(caplog): propagate_all_anchors_impl(glyphs) assert len(caplog.records) == 2 - assert ( - caplog.records[0].message == "Cycle detected in composite glyph 'acutecomb.alt'" - ) - assert caplog.records[1].message == "Cycle detected in composite glyph 'gravecomb'" + assert caplog.records[0].message == "glyph 'acutecomb.alt' has cyclical components" + assert caplog.records[1].message == "glyph 'gravecomb' has cyclical components" def test_remove_exit_anchor_on_component():