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

markFeatureWriter: Support contextual anchors #869

Merged
merged 5 commits into from
Sep 19, 2024
Merged
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
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
Loading