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

Implement GSLayer.layerKey() method #1018

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
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
157 changes: 137 additions & 20 deletions Lib/glyphsLib/classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.

from __future__ import annotations

import copy
import logging
Expand Down Expand Up @@ -40,6 +41,7 @@
Rect,
Transform,
UnicodesList,
floatToString2,
floatToString5,
parse_datetime,
parse_float_or_int,
Expand Down Expand Up @@ -1245,6 +1247,17 @@ def __init__(self, name="", tag="", hidden=False):
def __eq__(self, other):
return self.name == other.name and self.axisTag == other.axisTag

@property
def shortAxisTag(self):
shortAxisTagMapping = {
"ital": "it",
"opsz": "oz",
"slnt": "sl",
"wdth": "wd",
"wght": "wg",
}
return shortAxisTagMapping.get(self.axisTag, self.axisTag)


GSAxis._add_parsers(
[
Expand Down Expand Up @@ -3747,6 +3760,79 @@ def name(self):
def name(self, value):
self._name = value

def layerKey(self) -> str | None:
"""Return a string that identifies a special layer with given attributes.

In Glyphs.app's Python interface, this is a private method used to match and
look up special layers by their attributes when their layerId isn't enough
(only 'master' layers have the same layerId across all the glyphs, for other
layers it's unique). The layerKey string is used as the first parameter of
the GSGlyph.layerForKey_masterId_ method, e.g. to resolve component references
in the componentLayer property (not implemented yet in glyphsLib): see
https://github.com/googlefonts/glyphsLib/pull/853#issuecomment-1403454327
"""
parts = []
# An optional prefix defines whether the layer contains any color information.
# While a layer could in theory contain all four different types of color
# attributes (even though it makes no sense to mix vector & bitmap formats!),
# there seem to be some sort of hierarchy or implicit order between them when
# composing the layerKey: COLRv0 > COLRv1 > SBIX > SVG
if self._is_color_palette_layer() or self._is_color_layer():
parts.append("Color")
if self._is_color_palette_layer():
index = self._color_palette_index()
if index == 0xFFFF:
index = "*"
color_key = str(index)
# There can be more than one layer with the same palette index that
# are associated with the same master, so we need to add a counter
# suffix to distinguish them.
count = 0
for layer in self.parent.layers:
if layer is self:
break
if (
layer.associatedMasterId == self.associatedMasterId
and layer._color_palette_index() == index
):
count += 1
if count > 0:
color_key = f"{color_key}_{count}"
parts.append(color_key)
elif self._is_sbix_color_layer():
parts.extend(["iColor", str(self._sbix_strike())])
elif self._is_svg_layer():
parts.append("svg")
# The rest of the layerKey optionally defines the associated master name and
# axis range for alternate layers, and/or the intermediate layer coordinates.
# Numbers are normalized and rounded to 2 decimals.
ntos = floatToString2 # for brevity
if self._is_bracket_layer():
axes = self.parent.parent.axes
master = self.master
if master is None:
# no/invalid associated master id? no layerKey
return None
parts.append(master.name)
bracket_ranges = ",".join(
(
(f"{ntos(axis_min)}‹{axis.shortAxisTag}‹{ntos(axis_max)}")
if axis_min is not None and axis_max is not None
else (
f"{ntos(axis_min)}‹{axis.shortAxisTag}"
if axis_min is not None
else f"{axis.shortAxisTag}‹{ntos(axis_max)}"
)
)
for axis, (axis_min, axis_max) in zip(axes, self._bracket_axis_rules())
if axis_min is not None or axis_max is not None
)
parts.append(f"[{bracket_ranges}]")
if self._is_brace_layer():
parts.append(f"{{{', '.join(ntos(v) for v in self._brace_coordinates())}}}")
# if neither of the above, the layer's got no layerKey
return " ".join(parts) or None

anchors = property(
lambda self: LayerAnchorsProxy(self),
lambda self, value: LayerAnchorsProxy(self).setter(value),
Expand Down Expand Up @@ -3922,40 +4008,42 @@ def _is_bracket_layer(self):
return "axisRules" in self.attributes # Glyphs 3
return re.match(self.BRACKET_LAYER_RE, self.name) # Glyphs 2

def _bracket_info(self, axes):
# Returns a region expressed as a {axis_tag: (min, max)} box
# (dictionary), once the axes have been computed
def _bracket_axis_rules(self):
if not self._is_bracket_layer():
return {}

return
if self.parent.parent.format_version > 2:
# Glyphs 3
info = {}
for axis, rule in zip(axes, self.attributes["axisRules"]):
if "min" not in rule and "max" not in rule:
continue
# Rules are expressed in designspace coordinates,
# so map appropriately.
designspace_min, designspace_max = designspace_min_max(axis)
axis_min = rule.get("min", designspace_min)
axis_max = rule.get("max", designspace_max)
for rule in self.attributes["axisRules"]:
axis_min = rule.get("min")
axis_max = rule.get("max")
if isinstance(axis_min, str):
axis_min = float(axis_min)
if isinstance(axis_max, str):
axis_max = float(axis_max)
info[axis.tag] = (axis_min, axis_max)
return info
yield (axis_min, axis_max)
return

# Glyphs 2
m = re.match(self.BRACKET_LAYER_RE, self.name)
axis = axes[0] # For glyphs 2
designspace_min, designspace_max = designspace_min_max(axis)
reverse = m.group("first_bracket") == "]"
bracket_crossover = int(m.group("value"))
if reverse:
return {axis.tag: (designspace_min, bracket_crossover)}
yield (None, bracket_crossover)
else:
return {axis.tag: (bracket_crossover, designspace_max)}
yield (bracket_crossover, None)

def _bracket_info(self, axes):
# Returns a region expressed as a {axis_tag: (min, max)} box
# (dictionary), once the axes have been computed
info = {}
for axis, (axis_min, axis_max) in zip(axes, self._bracket_axis_rules()):
# Rules are expressed in designspace coordinates,
# so map appropriately.
designspace_min, designspace_max = designspace_min_max(axis)
axis_min = axis_min if axis_min is not None else designspace_min
axis_max = axis_max if axis_max is not None else designspace_max
info[axis.tag] = (axis_min, axis_max)
return info

def _is_brace_layer(self):
if self.parent.parent.format_version > 2:
Expand Down Expand Up @@ -3990,6 +4078,35 @@ def _brace_layer_name(self):
# Glyphs 2
return self.name

ICOLOR_LAYER_RE = re.compile(r"^iColor (?P<strike>\d+)$")

def _is_sbix_color_layer(self):
if self.parent.parent.format_version > 2:
return "sbixStrike" in self.attributes # Glyphs 3
return re.match(self.ICOLOR_LAYER_RE, self.name.strip()) # Glyphs 2

def _sbix_strike(self):
if not self._is_sbix_color_layer():
return None

if self.parent.parent.format_version > 2:
# Glyphs 3
return int(self.attributes["sbixStrike"])

# Glyphs 2
m = re.match(self.ICOLOR_LAYER_RE, self.name)
strike = m.group("strike")
return int(strike)

def _is_svg_layer(self):
# Glyphs 3 only; not even sure if Glyphs 2 ever supported SVG layers
return bool(self.attributes.get("svg"))

def _is_color_layer(self):
# COLRv1-style gradients etc. Glyph 3 only, no Glyphs 2 equivalent.
# Not to be confused with so-called "color palette layers" (COLRv0).
return bool(self.attributes.get("color"))

COLOR_PALETTE_LAYER_RE = re.compile(r"^Color (?P<index>\*|\d+)$")

def _is_color_palette_layer(self):
Expand Down
10 changes: 10 additions & 0 deletions Lib/glyphsLib/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,16 @@ def writeIntlist(val):
return _mutate_list(str, val)


def floatToString2(f: float) -> str:
"""Return float f as a string with two decimal places without trailing zeros
and dot.

Intended for ('brace') intermediate layer coordinates and ('bracket') alternate
layer axis ranges.
"""
return f"{f:.2f}".rstrip("0").rstrip(".")


def floatToString3(f: float) -> str:
"""Return float f as a string with three decimal places without trailing zeros
and dot.
Expand Down
40 changes: 40 additions & 0 deletions tests/classes_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -1890,5 +1890,45 @@ def test_indxing_by_name(self):
assert self.font.features["aalt"] in self.font.features


def test_GSLayer_layerKey(datadir):
# https://github.com/googlefonts/glyphsLib/issues/1017
font = GSFont(str(datadir / "LayerKeysTestv3.glyphs"))

glyph = font.glyphs["grinningFace"]
for i in (0, 1):
# master layers without attributes have no layerKey
layer = glyph.layers[i]
assert layer._is_master_layer
assert not layer.attributes
assert layer.layerKey() is None
assert glyph.layers[2].layerKey() == "Color 1"
assert glyph.layers[3].layerKey() == "Color 0"
assert glyph.layers[4].layerKey() == "Color 2"
# layer with existing palette index (and same associated master) gets de-duped
assert glyph.layers[2].associatedMasterId == glyph.layers[5].associatedMasterId
assert glyph.layers[5].layerKey() == "Color 0_1"
assert glyph.layers[6].layerKey() == "Color"

glyph = font.glyphs["grinningFaceWithSmilingEyes"]
# layers[2] and [3] have the same palette index but are associated with
# different masters do not get de-duped
assert glyph.layers[2].layerKey() == "Color 1"
assert glyph.layers[2].associatedMasterId != glyph.layers[3].associatedMasterId
assert glyph.layers[3].layerKey() == "Color 1"
# also note how multiple attributes can be combined in a key
# (in this case colorPalettes and brace coordinates}
assert glyph.layers[4].layerKey() == "Color 1_1 {45}"
assert glyph.layers[5].layerKey() == "{45}"

glyph = font.glyphs["dollar"]
# master layers with bracket axisRules attribute *do* have a layerKey
layer = glyph.layers[1]
assert layer._is_master_layer
assert "axisRules" in layer.attributes
assert layer.layerKey() == "Regular [45‹wg]"
assert glyph.layers[2].layerKey() == "Light [45‹wg]"
assert glyph.layers[3].layerKey() == "Regular []"


if __name__ == "__main__":
unittest.main()
Loading
Loading