Skip to content

Commit

Permalink
Merge pull request #921 from googlefonts/contextual-mark-writer
Browse files Browse the repository at this point in the history
Support contextual anchors
  • Loading branch information
khaledhosny authored Aug 2, 2023
2 parents b4d2f8e + 840f491 commit 9066b26
Show file tree
Hide file tree
Showing 6 changed files with 1,605 additions and 13 deletions.
21 changes: 8 additions & 13 deletions Lib/glyphsLib/builder/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,20 +204,15 @@
UFO2FT_COLOR_LAYERS_KEY = "com.github.googlei18n.ufo2ft.colorLayers"

UFO2FT_META_TABLE_KEY = PUBLIC_PREFIX + "openTypeMeta"
# ufo2ft KernFeatureWriter default to "skip" mode (i.e. do not write features
# if they are already present), while Glyphs.app always adds its automatic
# kerning to any user written kern lookups. So we need to pass custom "append"
# mode for the ufo2ft KernFeatureWriter whenever the GSFont contain a non-automatic
# 'kern' feature.
# See https://glyphsapp.com/tutorials/contextual-kerning
# NOTE: Even though we use the default "skip" mode for the MarkFeatureWriter,
# we still must include it this custom featureWriters list, as this is used
# instead of the default ufo2ft list of feature writers.
# This also means that if ufo2ft adds new writers to that default list, we
# would need to update this accordingly... :-/

DEFAULT_FEATURE_WRITERS = [
{"class": "KernFeatureWriter", "options": {"mode": "append"}},
{"class": "MarkFeatureWriter", "options": {"mode": "skip"}},
{"class": "KernFeatureWriter"},
{
"module": "glyphsLib.featureWriters.markFeatureWriter",
"class": "ContextualMarkFeatureWriter",
},
{"class": "GdefFeatureWriter"},
{"class": "CursFeatureWriter"},
]

DEFAULT_LAYER_NAME = PUBLIC_PREFIX + "default"
Expand Down
3 changes: 3 additions & 0 deletions Lib/glyphsLib/builder/font.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@

from .common import to_ufo_time, from_ufo_time
from .constants import (
DEFAULT_FEATURE_WRITERS,
UFO2FT_FEATURE_WRITERS_KEY,
UFO2FT_FILTERS_KEY,
APP_VERSION_LIB_KEY,
KEYBOARD_INCREMENT_KEY,
Expand Down Expand Up @@ -52,6 +54,7 @@ def to_ufo_font_attributes(self, family_name):
ufo.lib.setdefault(UFO2FT_FILTERS_KEY, []).append(
{"namespace": "glyphsLib.filters", "name": "eraseOpenCorners", "pre": True}
)
ufo.lib[UFO2FT_FEATURE_WRITERS_KEY] = DEFAULT_FEATURE_WRITERS

self.to_ufo_custom_params(ufo, font) # .custom_params
self.to_ufo_master_attributes(ufo, master) # .masters
Expand Down
Empty file.
249 changes: 249 additions & 0 deletions Lib/glyphsLib/featureWriters/markFeatureWriter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
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,
MarkToBasePos,
NamedAnchor,
MarkFeatureWriter,
quantize,
)


class ContextuallyAwareNamedAnchor(NamedAnchor):
__slots__ = (
"name",
"x",
"y",
"isMark",
"key",
"number",
"markClass",
"isContextual",
"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

return isMark, key, number, isContextual

def __init__(self, name, x, y, markClass=None, libData=None):
self.name = name
self.x = x
self.y = y
isMark, key, number, isContextual = 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.libData = libData


class ContextualMarkFeatureWriter(MarkFeatureWriter):
NamedAnchor = ContextuallyAwareNamedAnchor

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 = quantize(anchor.x, self.options.quantization)
y = quantize(anchor.y, self.options.quantization)
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
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)

for glyph, anchor in glyph_anchor_pair:
marks = ast.GlyphClass(
self.context.markClasses[anchor.key].glyphs.keys()
).asFea()
if "&" not in after:
after = after.replace("*", "* &")
# Replace & with mark name if present
contextual = after.replace("*", f"{glyph}")
contextual = contextual.replace("&", f"{marks}' lookup {lookupname}")
lkp.statements.append(
ast.Comment(f"pos {contextual}; # {glyph}/{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
Loading

0 comments on commit 9066b26

Please sign in to comment.