Skip to content

Commit

Permalink
markFeatureWriter: Support contextual anchors
Browse files Browse the repository at this point in the history
This merges glyphsLib’s ContextualMarkFeatureWriter subclass back into
MarkFeatureWriter. This is a mechanical merge with only the bare minimum
changes to get things working. Some bug fixes and refactoring will
happen in next commits.
  • Loading branch information
khaledhosny committed Sep 4, 2024
1 parent 12b68f0 commit baac9d9
Show file tree
Hide file tree
Showing 2 changed files with 134 additions and 16 deletions.
128 changes: 120 additions & 8 deletions Lib/ufo2ft/featureWriters/markFeatureWriter.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from collections import OrderedDict, defaultdict
from functools import partial

from ufo2ft.constants import INDIC_SCRIPTS, USE_SCRIPTS
from ufo2ft.constants import INDIC_SCRIPTS, USE_SCRIPTS, OBJECT_LIBS_KEY
from ufo2ft.featureWriters import BaseFeatureWriter, ast
from ufo2ft.util import (
classifyGlyphs,
Expand Down Expand Up @@ -127,9 +127,15 @@ def parseAnchorName(
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
Expand All @@ -156,25 +162,38 @@ def parseAnchorName(
else:
isMark = False

return isMark, key, number
isIgnorable = not key[0].isalpha()

return isMark, key, number, isContextual, isIgnorable


class NamedAnchor:
"""A position with a name, and an associated markClass."""

__slots__ = ("name", "x", "y", "isMark", "key", "number", "markClass")
__slots__ = (
"name",
"x",
"y",
"isMark",
"key",
"number",
"markClass",
"isContextual",
"isIgnorable",
"libData",
)

# subclasses can customize these to use different anchor naming schemes
markPrefix = MARK_PREFIX
ignoreRE = None
ligaSeparator = LIGA_SEPARATOR
ligaNumRE = LIGA_NUM_RE

def __init__(self, name, x, y, markClass=None):
def __init__(self, name, x, y, markClass=None, libData=None):
self.name = name
self.x = x
self.y = y
isMark, key, number = parseAnchorName(
isMark, key, number, isContextual, isIgnorable = parseAnchorName(
name,
markPrefix=self.markPrefix,
ligaSeparator=self.ligaSeparator,
Expand All @@ -190,6 +209,9 @@ def __init__(self, name, x, y, markClass=None):
self.key = key
self.number = number
self.markClass = markClass
self.isContextual = isContextual
self.isIgnorable = isIgnorable
self.libData = libData

@property
def markAnchorName(self):
Expand Down Expand Up @@ -357,7 +379,14 @@ def _getAnchorLists(self):
"duplicate anchor '%s' in glyph '%s'", anchorName, glyphName
)
x, y = self._getAnchor(glyphName, anchorName, anchor=anchor)
a = self.NamedAnchor(name=anchorName, x=x, y=y)
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())
Expand Down Expand Up @@ -854,6 +883,7 @@ def _makeAbvmOrBlwmFeature(self, tag, include):
def _makeFeatures(self):
ctx = self.context

# First do non-contextual lookups
ctx.groupedMarkToBaseAttachments = self._groupAttachments(
self._makeMarkToBaseAttachments()
)
Expand Down Expand Up @@ -889,7 +919,88 @@ def isNotAbvm(glyphName):
if feature is not None:
features[tag] = feature

return features
# Now do the contextual ones
# Arrange by context
by_context = defaultdict(list)
markGlyphNames = ctx.markGlyphNames

for glyphName, anchors in sorted(ctx.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 _getAbvmGlyphs(self):
glyphSet = set(self.getOrderedGlyphSet().keys())
Expand Down Expand Up @@ -937,7 +1048,7 @@ def _write(self):
newClassDefs = self._makeMarkClassDefinitions()
self._setBaseAnchorMarkClasses()

features = self._makeFeatures()
features, lookups = self._makeFeatures()
if not features:
return False

Expand All @@ -947,6 +1058,7 @@ def _write(self):
feaFile=feaFile,
markClassDefs=newClassDefs,
features=[features[tag] for tag in sorted(features.keys())],
lookups=lookups,
)

return True
22 changes: 14 additions & 8 deletions tests/featureWriters/markFeatureWriter_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,17 +34,23 @@ def testufo(FontClass):
@pytest.mark.parametrize(
"input_expected",
[
("top", (False, "top", None)),
("top_", (False, "top_", None)),
("top1", (False, "top1", None)),
("_bottom", (True, "bottom", None)),
("bottom_2", (False, "bottom", 2)),
("top_right_1", (False, "top_right", 1)),
("top", (False, "top", None, False, False)),
("top_", (False, "top_", None, False, False)),
("top1", (False, "top1", None, False, False)),
("_bottom", (True, "bottom", None, False, False)),
("bottom_2", (False, "bottom", 2, False, False)),
("top_right_1", (False, "top_right", 1, False, False)),
],
)
def test_parseAnchorName(input_expected):
anchorName, (isMark, key, number) = input_expected
assert parseAnchorName(anchorName) == (isMark, key, number)
anchorName, (isMark, key, number, isContextual, isIgnorable) = input_expected
assert parseAnchorName(anchorName) == (
isMark,
key,
number,
isContextual,
isIgnorable,
)


def test_parseAnchorName_invalid():
Expand Down

0 comments on commit baac9d9

Please sign in to comment.