Skip to content

Commit

Permalink
Merge pull request #869 from googlefonts/contextual-mark-feature-writer
Browse files Browse the repository at this point in the history
markFeatureWriter: Support contextual anchors
  • Loading branch information
khaledhosny authored Sep 19, 2024
2 parents eaea1cf + e6ec270 commit 6ad2ece
Show file tree
Hide file tree
Showing 34 changed files with 1,257 additions and 16 deletions.
144 changes: 136 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, OBJECT_LIBS_KEY, USE_SCRIPTS
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 = key and 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 @@ -620,6 +649,9 @@ def _makeMarkToBaseAttachments(self):
# skip '_1', '_2', etc. suffixed anchors for this lookup
# type; these will be are added in the mark2liga lookup
continue
if anchor.isContextual:
# skip contextual anchors. They are handled separately.
continue
assert not anchor.isMark
baseMarks.append(anchor)
if not baseMarks:
Expand All @@ -640,6 +672,9 @@ def _makeMarkToMarkAttachments(self):
# skip anchors for which no mark class is defined
if anchor.markClass is None or anchor.isMark:
continue
if anchor.isContextual:
# skip contextual anchors. They are handled separately.
continue
if anchor.number is not None:
self.log.warning(
"invalid ligature anchor '%s' in mark glyph '%s'; " "skipped",
Expand Down Expand Up @@ -671,6 +706,9 @@ def _makeMarkToLigaAttachments(self):
if number is None:
# we handled these in the mark2base lookup
continue
if anchor.isContextual:
# skip contextual anchors. They are handled separately.
continue
# unnamed anchors with only a number suffix "_1", "_2", etc.
# are understood as the ligature component having <anchor NULL>
if not anchor.key:
Expand Down Expand Up @@ -766,6 +804,91 @@ def _makeMarkFeature(self, include):
feature.statements.append(ligaLkp)
return feature

def _makeContextualMarkFeature(self, feature):
ctx = self.context

# 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 feature, []

if feature is None:
feature = ast.FeatureBlock("mark")

# Pull the lookups from the feature and replace them with lookup references,
# to ensure the order is correct
lookups = feature.statements
feature.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};")
)
feature.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 feature, lookups

def _makeMkmkFeature(self, include):
feature = ast.FeatureBlock("mkmk")

Expand Down Expand Up @@ -854,6 +977,7 @@ def _makeAbvmOrBlwmFeature(self, tag, include):
def _makeFeatures(self):
ctx = self.context

# First do non-contextual lookups
ctx.groupedMarkToBaseAttachments = self._groupAttachments(
self._makeMarkToBaseAttachments()
)
Expand All @@ -871,11 +995,14 @@ def isNotAbvm(glyphName):
return glyphName in notAbvmGlyphs

features = {}
lookups = []
todo = ctx.todo
if "mark" in todo:
mark = self._makeMarkFeature(include=isNotAbvm)
mark, markLookups = self._makeContextualMarkFeature(mark)
if mark is not None:
features["mark"] = mark
lookups.extend(markLookups)
if "mkmk" in todo:
mkmk = self._makeMkmkFeature(include=isNotAbvm)
if mkmk is not None:
Expand All @@ -889,7 +1016,7 @@ def isNotAbvm(glyphName):
if feature is not None:
features[tag] = feature

return features
return features, lookups

def _getAbvmGlyphs(self):
glyphSet = set(self.getOrderedGlyphSet().keys())
Expand Down Expand Up @@ -937,7 +1064,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 +1074,7 @@ def _write(self):
feaFile=feaFile,
markClassDefs=newClassDefs,
features=[features[tag] for tag in sorted(features.keys())],
lookups=lookups,
)

return True
37 changes: 37 additions & 0 deletions tests/data/ContextualAnchorsTest-Regular.ufo/features.fea
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Prefix: Languagesystems
# automatic
languagesystem DFLT dflt;

languagesystem arab dflt;


feature aalt {
# automatic
feature init;
feature medi;
feature fina;

} aalt;

feature ccmp {
sub beh-ar by behDotless-ar dotbelow-ar;

} ccmp;

feature init {
# automatic
sub behDotless-ar by behDotless-ar.init;

} init;

feature medi {
# automatic
sub behDotless-ar by behDotless-ar.medi;

} medi;

feature fina {
# automatic
sub behDotless-ar by behDotless-ar.fina;

} fina;
76 changes: 76 additions & 0 deletions tests/data/ContextualAnchorsTest-Regular.ufo/fontinfo.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?xml version='1.0' encoding='UTF-8'?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>ascender</key>
<integer>800</integer>
<key>capHeight</key>
<integer>700</integer>
<key>descender</key>
<integer>-200</integer>
<key>familyName</key>
<string>Contextual Anchors Test</string>
<key>guidelines</key>
<array>
<dict>
<key>angle</key>
<integer>0</integer>
<key>name</key>
<string> [locked]</string>
<key>x</key>
<integer>-126</integer>
<key>y</key>
<integer>90</integer>
</dict>
</array>
<key>italicAngle</key>
<integer>0</integer>
<key>openTypeHeadCreated</key>
<string>2023/07/31 15:25:34</string>
<key>openTypeOS2Type</key>
<array>
<integer>3</integer>
</array>
<key>postscriptBlueValues</key>
<array>
<integer>-12</integer>
<integer>0</integer>
<integer>480</integer>
<integer>492</integer>
<integer>700</integer>
<integer>712</integer>
<integer>800</integer>
<integer>812</integer>
</array>
<key>postscriptOtherBlues</key>
<array>
<integer>-212</integer>
<integer>-200</integer>
</array>
<key>postscriptStemSnapH</key>
<array>
<integer>80</integer>
<integer>88</integer>
<integer>91</integer>
</array>
<key>postscriptStemSnapV</key>
<array>
<integer>90</integer>
<integer>93</integer>
</array>
<key>postscriptUnderlinePosition</key>
<integer>-100</integer>
<key>postscriptUnderlineThickness</key>
<integer>50</integer>
<key>styleName</key>
<string>Regular</string>
<key>unitsPerEm</key>
<integer>1000</integer>
<key>versionMajor</key>
<integer>1</integer>
<key>versionMinor</key>
<integer>0</integer>
<key>xHeight</key>
<integer>480</integer>
</dict>
</plist>
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?xml version='1.0' encoding='UTF-8'?>
<glyph name="beh-ar" format="2">
<advance width="550"/>
<unicode hex="0628"/>
<outline>
<component base="behDotless-ar"/>
<component base="dotbelow-ar" xOffset="140" yOffset="-75"/>
</outline>
</glyph>
Loading

0 comments on commit 6ad2ece

Please sign in to comment.