Skip to content

Commit

Permalink
Merge pull request #1030 from googlefonts/move-contextual-mark-featur…
Browse files Browse the repository at this point in the history
…e-writer-to-ufo2ft

markFeatureWriter: Use new ufo2ft MarkFeatureWriter
  • Loading branch information
khaledhosny authored Sep 19, 2024
2 parents 6b9c4ee + d436f8f commit 664779c
Show file tree
Hide file tree
Showing 6 changed files with 29 additions and 306 deletions.
5 changes: 1 addition & 4 deletions Lib/glyphsLib/builder/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,10 +208,7 @@
DEFAULT_FEATURE_WRITERS = [
{"class": "CursFeatureWriter"},
{"class": "KernFeatureWriter"},
{
"module": "glyphsLib.featureWriters.markFeatureWriter",
"class": "ContextualMarkFeatureWriter",
},
{"class": "MarkFeatureWriter"},
{"class": "GdefFeatureWriter"},
]

Expand Down
277 changes: 2 additions & 275 deletions Lib/glyphsLib/featureWriters/markFeatureWriter.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion requirements-dev.in
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading

0 comments on commit 664779c

Please sign in to comment.