From d436f8f3ccf3634f56dbcb337a95591a1bac1531 Mon Sep 17 00:00:00 2001 From: Khaled Hosny Date: Thu, 19 Sep 2024 14:27:45 +0300 Subject: [PATCH] markFeatureWriter: Use new ufo2ft MarkFeatureWriter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The code here was merged back to ufo2ft’s MarkFeatureWriter. Keeping ContextualMarkFeatureWriter as stub just in case someone is using it directly. Keeping the tests too, to exercise exporting anchor libData to UFO. --- Lib/glyphsLib/builder/constants.py | 5 +- .../featureWriters/markFeatureWriter.py | 277 +----------------- requirements-dev.in | 2 +- requirements-dev.txt | 37 +-- requirements.txt | 4 +- tests/feature_writers_test.py | 10 +- 6 files changed, 29 insertions(+), 306 deletions(-) diff --git a/Lib/glyphsLib/builder/constants.py b/Lib/glyphsLib/builder/constants.py index 49a90c406..b2b300ac2 100644 --- a/Lib/glyphsLib/builder/constants.py +++ b/Lib/glyphsLib/builder/constants.py @@ -208,10 +208,7 @@ DEFAULT_FEATURE_WRITERS = [ {"class": "CursFeatureWriter"}, {"class": "KernFeatureWriter"}, - { - "module": "glyphsLib.featureWriters.markFeatureWriter", - "class": "ContextualMarkFeatureWriter", - }, + {"class": "MarkFeatureWriter"}, {"class": "GdefFeatureWriter"}, ] diff --git a/Lib/glyphsLib/featureWriters/markFeatureWriter.py b/Lib/glyphsLib/featureWriters/markFeatureWriter.py index 60a0d56a1..45d506c8e 100644 --- a/Lib/glyphsLib/featureWriters/markFeatureWriter.py +++ b/Lib/glyphsLib/featureWriters/markFeatureWriter.py @@ -1,278 +1,5 @@ -from collections import OrderedDict, defaultdict -import re - -from glyphsLib.builder.constants import OBJECT_LIBS_KEY -from ufo2ft.featureWriters import ast -from ufo2ft.featureWriters.markFeatureWriter import ( - MARK_PREFIX, - LIGA_SEPARATOR, - LIGA_NUM_RE, - MarkFeatureWriter, - MarkToBasePos, - NamedAnchor, -) -from ufo2ft.util import quantize - - -class ContextuallyAwareNamedAnchor(NamedAnchor): - __slots__ = ( - "name", - "x", - "y", - "isMark", - "key", - "number", - "markClass", - "isContextual", - "isIgnorable", - "libData", - ) - - @classmethod - def parseAnchorName( - cls, - anchorName, - markPrefix=MARK_PREFIX, - ligaSeparator=LIGA_SEPARATOR, - ligaNumRE=LIGA_NUM_RE, - ignoreRE=None, - ): - """Parse anchor name and return a tuple that specifies: - 1) whether the anchor is a "mark" anchor (bool); - 2) the "key" name of the anchor, i.e. the name after stripping all the - prefixes and suffixes, which identifies the class it belongs to (str); - 3) An optional number (int), starting from 1, which identifies that index - of the ligature component the anchor refers to. - - The 'ignoreRE' argument is an optional regex pattern (str) identifying - sub-strings in the anchor name that should be ignored when parsing the - three elements above. - """ - number = None - isContextual = False - if ignoreRE is not None: - anchorName = re.sub(ignoreRE, "", anchorName) - - if anchorName[0] == "*": - isContextual = True - anchorName = anchorName[1:] - anchorName = re.sub(r"\..*", "", anchorName) - - m = ligaNumRE.match(anchorName) - if not m: - key = anchorName - else: - number = m.group(1) - key = anchorName.rstrip(number) - separator = ligaSeparator - if key.endswith(separator): - assert separator - key = key[: -len(separator)] - number = int(number) - else: - # not a valid ligature anchor name - key = anchorName - number = None - - if anchorName.startswith(markPrefix) and key: - if number is not None: - raise ValueError("mark anchor cannot be numbered: %r" % anchorName) - isMark = True - key = key[len(markPrefix) :] - if not key: - raise ValueError("mark anchor key is nil: %r" % anchorName) - else: - isMark = False - - isIgnorable = not key[0].isalpha() - - return isMark, key, number, isContextual, isIgnorable - - def __init__(self, name, x, y, markClass=None, libData=None): - self.name = name - self.x = x - self.y = y - isMark, key, number, isContextual, isIgnorable = self.parseAnchorName( - name, - markPrefix=self.markPrefix, - ligaSeparator=self.ligaSeparator, - ligaNumRE=self.ligaNumRE, - ignoreRE=self.ignoreRE, - ) - if number is not None: - if number < 1: - raise ValueError("ligature component indexes must start from 1") - else: - assert key, name - self.isMark = isMark - self.key = key - self.number = number - self.markClass = markClass - self.isContextual = isContextual - self.isIgnorable = isIgnorable - self.libData = libData +from ufo2ft.featureWriters.markFeatureWriter import MarkFeatureWriter class ContextualMarkFeatureWriter(MarkFeatureWriter): - NamedAnchor = ContextuallyAwareNamedAnchor - - def _getAnchor(self, glyphName, anchorName, anchor=None): - # the variable FEA aware method is defined with ufo2ft v3; make sure we don't - # fail but continue to work unchanged with older ufo2ft MarkFeatureWriter API. - try: - getter = super()._getAnchor - except AttributeError: - x = anchor.x - y = anchor.y - if hasattr(self.options, "quantization"): - x = quantize(x, self.options.quantization) - y = quantize(y, self.options.quantization) - return x, y - else: - return getter(glyphName, anchorName, anchor=anchor) - - def _getAnchorLists(self): - gdefClasses = self.context.gdefClasses - if gdefClasses.base is not None: - # only include the glyphs listed in the GDEF.GlyphClassDef groups - include = gdefClasses.base | gdefClasses.ligature | gdefClasses.mark - else: - # no GDEF table defined in feature file, include all glyphs - include = None - result = OrderedDict() - for glyphName, glyph in self.getOrderedGlyphSet().items(): - if include is not None and glyphName not in include: - continue - anchorDict = OrderedDict() - for anchor in glyph.anchors: - anchorName = anchor.name - if not anchorName: - self.log.warning( - "unnamed anchor discarded in glyph '%s'", glyphName - ) - continue - if anchorName in anchorDict: - self.log.warning( - "duplicate anchor '%s' in glyph '%s'", anchorName, glyphName - ) - x, y = self._getAnchor(glyphName, anchorName, anchor=anchor) - libData = None - if anchor.identifier: - libData = glyph.lib[OBJECT_LIBS_KEY].get(anchor.identifier) - a = self.NamedAnchor(name=anchorName, x=x, y=y, libData=libData) - if a.isContextual and not libData: - continue - if a.isIgnorable: - continue - anchorDict[anchorName] = a - if anchorDict: - result[glyphName] = list(anchorDict.values()) - return result - - def _makeFeatures(self): - features = super()._makeFeatures() - # Now do the contextual ones - - # Arrange by context - by_context = defaultdict(list) - markGlyphNames = self.context.markGlyphNames - - for glyphName, anchors in sorted(self.context.anchorLists.items()): - if glyphName in markGlyphNames: - continue - for anchor in anchors: - if not anchor.isContextual: - continue - anchor_context = anchor.libData["GPOS_Context"].strip() - by_context[anchor_context].append((glyphName, anchor)) - if not by_context: - return features, [] - - # Pull the lookups from the feature and replace them with lookup references, - # to ensure the order is correct - lookups = features["mark"].statements - features["mark"].statements = [ - ast.LookupReferenceStatement(lu) for lu in lookups - ] - - dispatch_lookups = {} - # We sort the full context by longest first. This isn't perfect - # but it gives us the best chance that more specific contexts - # (typically longer) will take precedence over more general ones. - for ix, (fullcontext, glyph_anchor_pair) in enumerate( - sorted(by_context.items(), key=lambda x: -len(x[0])) - ): - # Make the contextual lookup - lookupname = "ContextualMark_%i" % ix - if ";" in fullcontext: - before, after = fullcontext.split(";") - # I know it's not really a comment but this is the easiest way - # to get the lookup flag in there without reparsing it. - else: - after = fullcontext - before = "" - after = after.strip() - if before not in dispatch_lookups: - dispatch_lookups[before] = ast.LookupBlock( - "ContextualMarkDispatch_%i" % len(dispatch_lookups.keys()) - ) - if before: - dispatch_lookups[before].statements.append( - ast.Comment(f"{before};") - ) - features["mark"].statements.append( - ast.LookupReferenceStatement(dispatch_lookups[before]) - ) - lkp = dispatch_lookups[before] - lkp.statements.append(ast.Comment(f"# {after}")) - lookup = ast.LookupBlock(lookupname) - for glyph, anchor in glyph_anchor_pair: - lookup.statements.append(MarkToBasePos(glyph, [anchor]).asAST()) - lookups.append(lookup) - - # Insert mark glyph names after base glyph names if not specified otherwise. - if "&" not in after: - after = after.replace("*", "* &") - - # Group base glyphs by anchor - glyphs = {} - for glyph, anchor in glyph_anchor_pair: - glyphs.setdefault(anchor.key, [anchor, []])[1].append(glyph) - - for anchor, bases in glyphs.values(): - bases = " ".join(bases) - marks = ast.GlyphClass( - self.context.markClasses[anchor.key].glyphs.keys() - ).asFea() - - # Replace * with base glyph names - contextual = after.replace("*", f"[{bases}]") - - # Replace & with mark glyph names - contextual = contextual.replace("&", f"{marks}' lookup {lookupname}") - lkp.statements.append(ast.Comment(f"pos {contextual}; # {anchor.name}")) - - lookups.extend(dispatch_lookups.values()) - - return features, lookups - - def _write(self): - self._pruneUnusedAnchors() - - newClassDefs = self._makeMarkClassDefinitions() - self._setBaseAnchorMarkClasses() - - features, lookups = self._makeFeatures() - if not features: - return False - - feaFile = self.context.feaFile - - self._insert( - feaFile=feaFile, - markClassDefs=newClassDefs, - features=[features[tag] for tag in sorted(features.keys())], - lookups=lookups, - ) - - return True + pass diff --git a/requirements-dev.in b/requirements-dev.in index e1be9f06a..a00e40e68 100644 --- a/requirements-dev.in +++ b/requirements-dev.in @@ -10,5 +10,5 @@ fonttools[ufo,unicode] >= 4.38.0 # extras ufoNormalizer>=0.3.2 defcon>=0.6.0 -ufo2ft>=3.0.0b1 +ufo2ft>=3.3.0 skia-pathops diff --git a/requirements-dev.txt b/requirements-dev.txt index 8a171c0d5..3a00a9143 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -6,9 +6,9 @@ # appdirs==1.4.4 # via fs -attrs==23.2.0 +attrs==24.2.0 # via flake8-bugbear -black==24.3.0 +black==24.8.0 # via -r requirements-dev.in booleanoperations==0.9.0 # via ufo2ft @@ -16,71 +16,74 @@ cffsubr==0.3.0 # via ufo2ft click==8.1.7 # via black -coverage==7.4.0 +coverage==7.6.1 # via -r requirements-dev.in defcon==0.10.3 # via -r requirements-dev.in -execnet==2.0.2 +execnet==2.1.1 # via pytest-xdist -flake8==7.0.0 +flake8==7.1.1 # via # -r requirements-dev.in # flake8-bugbear -flake8-bugbear==24.1.17 +flake8-bugbear==24.8.19 # via -r requirements-dev.in -fonttools[ufo,unicode]==4.47.2 +fontmath==0.9.4 + # via ufo2ft +fonttools[ufo,unicode]==4.53.1 # via # -r requirements-dev.in # booleanoperations # cffsubr # defcon + # fontmath # ufo2ft fs==2.4.16 # via fonttools iniconfig==2.0.0 # via pytest -lxml==5.1.0 +lxml==5.3.0 # via xmldiff mccabe==0.7.0 # via flake8 mypy-extensions==1.0.0 # via black -packaging==23.2 +packaging==24.1 # via # black # pytest pathspec==0.12.1 # via black -platformdirs==4.1.0 +platformdirs==4.3.6 # via black -pluggy==1.4.0 +pluggy==1.5.0 # via pytest pyclipper==1.3.0.post5 # via booleanoperations -pycodestyle==2.11.1 +pycodestyle==2.12.1 # via flake8 pyflakes==3.2.0 # via flake8 -pytest==7.4.4 +pytest==8.3.3 # via # -r requirements-dev.in # pytest-randomly # pytest-xdist pytest-randomly==3.15.0 # via -r requirements-dev.in -pytest-xdist==3.5.0 +pytest-xdist==3.6.1 # via -r requirements-dev.in six==1.16.0 # via fs skia-pathops==0.8.0.post1 # via -r requirements-dev.in -ufo2ft==3.0.0b1 +ufo2ft==3.3.0 # via -r requirements-dev.in -ufonormalizer==0.6.1 +ufonormalizer==0.6.2 # via -r requirements-dev.in unicodedata2==15.1.0 # via fonttools -xmldiff==2.6.3 +xmldiff==2.7.0 # via -r requirements-dev.in # The following packages are considered to be unsafe in a requirements file: diff --git a/requirements.txt b/requirements.txt index 6ac16c9e0..b83fe4038 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,9 +6,9 @@ # appdirs==1.4.4 # via fs -attrs==23.2.0 +attrs==24.2.0 # via ufolib2 -fonttools[ufo,unicode]==4.47.2 +fonttools[ufo,unicode]==4.53.1 # via # glyphsLib (setup.cfg) # ufolib2 diff --git a/tests/feature_writers_test.py b/tests/feature_writers_test.py index d7cd2b3a1..3ef3a1fa7 100644 --- a/tests/feature_writers_test.py +++ b/tests/feature_writers_test.py @@ -38,8 +38,7 @@ def test_contextual_anchors(datadir): " lookupflag UseMarrkFilteringSet [twodotshorizontalbelow];\n" " # reh-ar * behDotess-ar.medi &\n" " pos reh-ar [behDotless-ar.init] behDotess-ar.medi" - " [dotbelow-ar twodotsverticalbelow-ar twodotshorizontalbelow-ar]'" - " lookup ContextualMark_0; # *bottom.twodots\n" + " @MC_bottom' lookup ContextualMark_0;\n" "} ContextualMarkDispatch_0;\n" ) @@ -49,8 +48,7 @@ def test_contextual_anchors(datadir): " lookupflag UseMarrkFilteringSet [twodotsverticalbelow];\n" " # reh-ar *\n" " pos reh-ar [behDotless-ar.init behDotless-ar.init.alt]" - " [dotbelow-ar twodotsverticalbelow-ar twodotshorizontalbelow-ar]'" - " lookup ContextualMark_1; # *bottom.vtwodots\n" + " @MC_bottom' lookup ContextualMark_1;\n" "} ContextualMarkDispatch_1;\n" ) @@ -58,9 +56,7 @@ def test_contextual_anchors(datadir): assert str(lookup) == ( "lookup ContextualMarkDispatch_2 {\n" " # reh-ar *\n" - " pos reh-ar [behDotless-ar.init] " - "[dotbelow-ar twodotsverticalbelow-ar twodotshorizontalbelow-ar]'" - " lookup ContextualMark_2; # *bottom\n" + " pos reh-ar [behDotless-ar.init] @MC_bottom' lookup ContextualMark_2;\n" "} ContextualMarkDispatch_2;\n" )