diff --git a/Lib/ufo2ft/__init__.py b/Lib/ufo2ft/__init__.py
index 89f4ba4a..5f9e61d7 100644
--- a/Lib/ufo2ft/__init__.py
+++ b/Lib/ufo2ft/__init__.py
@@ -18,6 +18,11 @@
"compileVariableCFF2s",
]
+try:
+ from ._version import version as __version__
+except ImportError:
+ __version__ = "0.0.0+unknown"
+
def compileTTF(ufo, **kwargs):
"""Create FontTools TrueType font from a UFO.
@@ -168,6 +173,30 @@ def compileVariableTTFs(designSpaceDoc, **kwargs):
def compileInterpolatableTTFsFromDS(designSpaceDoc, **kwargs):
+ """Create FontTools TrueType fonts from the DesignSpaceDocument UFO sources
+ with interpolatable outlines. Cubic curves are converted compatibly to
+ quadratic curves using the Cu2Qu conversion algorithm.
+
+ If the Designspace contains a "public.skipExportGlyphs" lib key, these
+ glyphs will not be exported to the final font. If these glyphs are used as
+ components in any other glyph, those components get decomposed. If the lib
+ key doesn't exist in the Designspace, all glyphs are exported (keys in
+ individual UFOs are ignored). UFO groups and kerning will be pruned of
+ skipped glyphs.
+
+ The DesignSpaceDocument should contain SourceDescriptor objects with 'font'
+ attribute set to an already loaded defcon.Font object (or compatible UFO
+ Font class). If 'font' attribute is unset or None, an AttributeError exception
+ is thrown.
+
+ Return a copy of the DesignSpaceDocument object (or the same one if
+ inplace=True) with the source's 'font' attribute set to the corresponding
+ TTFont instance.
+
+ For sources that have the 'layerName' attribute defined, the corresponding TTFont
+ object will contain only a minimum set of tables ("head", "hmtx", "glyf", "loca",
+ "maxp", "post" and "vmtx"), and no OpenType layout tables.
+ """
return InterpolatableTTFCompiler(**kwargs).compile_designspace(designSpaceDoc)
diff --git a/Lib/ufo2ft/_compilers/baseCompiler.py b/Lib/ufo2ft/_compilers/baseCompiler.py
index b2704f8f..4175cd0f 100644
--- a/Lib/ufo2ft/_compilers/baseCompiler.py
+++ b/Lib/ufo2ft/_compilers/baseCompiler.py
@@ -4,13 +4,19 @@
from dataclasses import dataclass
from typing import Callable, Optional, Type
+from fontTools import varLib
from fontTools.designspaceLib.split import splitInterpolable, splitVariableFonts
from fontTools.misc.loggingTools import Timer
from fontTools.otlLib.optimize.gpos import GPOS_COMPACT_MODE_ENV_KEY
from ufo2ft.constants import MTI_FEATURES_PREFIX
from ufo2ft.errors import InvalidDesignSpaceData
-from ufo2ft.featureCompiler import FeatureCompiler, MtiFeatureCompiler
+from ufo2ft.featureCompiler import (
+ FeatureCompiler,
+ MtiFeatureCompiler,
+ VariableFeatureCompiler,
+ _featuresCompatible,
+)
from ufo2ft.postProcessor import PostProcessor
from ufo2ft.util import (
_notdefGlyphFallback,
@@ -51,6 +57,7 @@ def compile(self, ufo):
with self.timer("preprocess UFO"):
glyphSet = self.preprocess(ufo)
with self.timer("compile a basic TTF"):
+ self.logger.info("Building OpenType tables")
font = self.compileOutlines(ufo, glyphSet)
if self.layerName is None and not self.skipFeatureCompilation:
self.compileFeatures(ufo, font, glyphSet=glyphSet)
@@ -169,8 +176,13 @@ class BaseInterpolatableCompiler(BaseCompiler):
"maxp", "post" and "vmtx"), and no OpenType layout tables.
"""
+ def compile_designspace(self, designSpaceDoc):
+ ufos = self._pre_compile_designspace(designSpaceDoc)
+ ttfs = self.compile(ufos)
+ return self._post_compile_designspace(designSpaceDoc, ttfs)
+
def _pre_compile_designspace(self, designSpaceDoc):
- ufos, self.layerNames = [], []
+ ufos, self.glyphSets, self.layerNames = [], [], []
for source in designSpaceDoc.sources:
if source.font is None:
raise AttributeError(
@@ -246,7 +258,16 @@ def _compileNeededSources(self, designSpaceDoc):
if source.name in sourcesToCompile:
sourcesByName[source.name] = source
+ # If the feature files are compatible between the sources, we can save
+ # time by building a variable feature file right at the end.
+ can_optimize_features = self.variableFeatures and _featuresCompatible(
+ designSpaceDoc
+ )
+ if can_optimize_features:
+ self.logger.info("Features are compatible across masters; building later")
+
originalSources = {}
+ originalGlyphsets = {}
# Compile all needed sources in each interpolable subspace to make sure
# they're all compatible; that also ensures that sub-vfs within the same
@@ -265,6 +286,7 @@ def _compileNeededSources(self, designSpaceDoc):
self.useProductionNames = False
save_postprocessor = self.postProcessorClass
self.postProcessorClass = None
+ self.skipFeatureCompilation = can_optimize_features
try:
ttfDesignSpace = self.compile_designspace(subDoc)
finally:
@@ -274,10 +296,18 @@ def _compileNeededSources(self, designSpaceDoc):
self.useProductionNames = save_production_names
# Stick TTFs back into original big DS
- for ttfSource in ttfDesignSpace.sources:
+ for ttfSource, glyphSet in zip(ttfDesignSpace.sources, self.glyphSets):
+ if can_optimize_features:
+ originalSources[ttfSource.name] = sourcesByName[ttfSource.name].font
sourcesByName[ttfSource.name].font = ttfSource.font
+ originalGlyphsets[ttfSource.name] = glyphSet
- return vfNameToBaseUfo, originalSources
+ return (
+ vfNameToBaseUfo,
+ can_optimize_features,
+ originalSources,
+ originalGlyphsets,
+ )
def compile_variable(self, designSpaceDoc):
if not self.inplace:
@@ -285,22 +315,77 @@ def compile_variable(self, designSpaceDoc):
(
vfNameToBaseUfo,
+ buildVariableFeatures,
originalSources,
+ originalGlyphsets,
) = self._compileNeededSources(designSpaceDoc)
if not vfNameToBaseUfo:
return {}
- self.logger.info("Building variable TTF fonts: %s", ", ".join(vfNameToBaseUfo))
+ vfNames = list(vfNameToBaseUfo.keys())
+ self.logger.info(
+ "Building variable font%s: %s",
+ "s" if len(vfNames) > 1 else "",
+ ", ".join(vfNames),
+ )
excludeVariationTables = self.excludeVariationTables
+ if buildVariableFeatures:
+ # Skip generating feature variations in varLib; we are handling
+ # the feature variations as part of compiling variable features,
+ # which we'll do later, so we don't need to produce them here.
+ excludeVariationTables = set(excludeVariationTables) | {"GSUB"}
with self.timer("merge fonts to variable"):
vfNameToTTFont = self._merge(designSpaceDoc, excludeVariationTables)
+ if buildVariableFeatures:
+ self.compile_all_variable_features(
+ designSpaceDoc, vfNameToTTFont, originalSources, originalGlyphsets
+ )
for vfName, varfont in list(vfNameToTTFont.items()):
vfNameToTTFont[vfName] = self.postprocess(
varfont, vfNameToBaseUfo[vfName], glyphSet=None
)
return vfNameToTTFont
+
+ def compile_all_variable_features(
+ self,
+ designSpaceDoc,
+ vfNameToTTFont,
+ originalSources,
+ originalGlyphsets,
+ debugFeatureFile=False,
+ ):
+ interpolableSubDocs = [
+ subDoc for _location, subDoc in splitInterpolable(designSpaceDoc)
+ ]
+ for subDoc in interpolableSubDocs:
+ for vfName, vfDoc in splitVariableFonts(subDoc):
+ if vfName not in vfNameToTTFont:
+ continue
+ ttFont = vfNameToTTFont[vfName]
+ # vfDoc is now full of TTFs, create a UFO-sourced equivalent
+ ufoDoc = vfDoc.deepcopyExceptFonts()
+ for ttfSource, ufoSource in zip(vfDoc.sources, ufoDoc.sources):
+ ufoSource.font = originalSources[ttfSource.name]
+ defaultGlyphset = originalGlyphsets[ufoDoc.findDefault().name]
+ self.logger.info(f"Compiling variable features for {vfName}")
+ self.compile_variable_features(ufoDoc, ttFont, defaultGlyphset)
+
+ def compile_variable_features(self, designSpaceDoc, ttFont, glyphSet):
+ default_ufo = designSpaceDoc.findDefault().font
+
+ featureCompiler = VariableFeatureCompiler(
+ default_ufo, designSpaceDoc, ttFont=ttFont, glyphSet=glyphSet
+ )
+ featureCompiler.compile()
+
+ if self.debugFeatureFile:
+ if hasattr(featureCompiler, "writeFeatures"):
+ featureCompiler.writeFeatures(self.debugFeatureFile)
+
+ # Add back feature variations, as the code above would overwrite them.
+ varLib.addGSUBFeatureVariations(ttFont, designSpaceDoc)
diff --git a/Lib/ufo2ft/_compilers/interpolatableOTFCompiler.py b/Lib/ufo2ft/_compilers/interpolatableOTFCompiler.py
index c9c668e1..9ebdde9d 100644
--- a/Lib/ufo2ft/_compilers/interpolatableOTFCompiler.py
+++ b/Lib/ufo2ft/_compilers/interpolatableOTFCompiler.py
@@ -7,6 +7,7 @@
from ufo2ft.constants import SPARSE_OTF_MASTER_TABLES, CFFOptimization
from ufo2ft.outlineCompiler import OutlineOTFCompiler
from ufo2ft.preProcessor import OTFPreProcessor
+from ufo2ft.util import prune_unknown_kwargs
from .baseCompiler import BaseInterpolatableCompiler
from .otfCompiler import OTFCompiler
@@ -28,10 +29,13 @@ class InterpolatableOTFCompiler(OTFCompiler, BaseInterpolatableCompiler):
skipFeatureCompilation: bool = False
excludeVariationTables: tuple = ()
- def compile_designspace(self, designSpaceDoc):
- self._pre_compile_designspace(designSpaceDoc)
+ # We can't use the same compile method as interpolatableTTFCompiler
+ # because that has a TTFInterpolatablePreProcessor which preprocesses
+ # all UFOs together, whereas we need to do the preprocessing one at
+ # at a time.
+ def compile(self, ufos):
otfs = []
- for source in designSpaceDoc.sources:
+ for ufo, layerName in zip(ufos, self.layerNames):
# There's a Python bug where dataclasses.asdict() doesn't work with
# dataclasses that contain a defaultdict.
save_extraSubstitutions = self.extraSubstitutions
@@ -39,20 +43,20 @@ def compile_designspace(self, designSpaceDoc):
args = {
**dataclasses.asdict(self),
**dict(
- layerName=source.layerName,
+ layerName=layerName,
removeOverlaps=False,
overlapsBackend=None,
optimizeCFF=CFFOptimization.NONE,
- _tables=SPARSE_OTF_MASTER_TABLES if source.layerName else None,
+ _tables=SPARSE_OTF_MASTER_TABLES if layerName else None,
),
}
# Remove interpolatable-specific args
- del args["variableFontNames"]
- del args["excludeVariationTables"]
+ args = prune_unknown_kwargs(args, OTFCompiler)
compiler = OTFCompiler(**args)
self.extraSubstitutions = save_extraSubstitutions
- otfs.append(compiler.compile(source.font))
- return self._post_compile_designspace(designSpaceDoc, otfs)
+ otfs.append(compiler.compile(ufo))
+ self.glyphSets.append(compiler._glyphSet)
+ return otfs
def _merge(self, designSpaceDoc, excludeVariationTables):
return varLib.build_many(
diff --git a/Lib/ufo2ft/_compilers/interpolatableTTFCompiler.py b/Lib/ufo2ft/_compilers/interpolatableTTFCompiler.py
index a15e2e07..a5d8b0fb 100644
--- a/Lib/ufo2ft/_compilers/interpolatableTTFCompiler.py
+++ b/Lib/ufo2ft/_compilers/interpolatableTTFCompiler.py
@@ -31,9 +31,9 @@ def compile(self, ufos):
if self.layerNames is None:
self.layerNames = [None] * len(ufos)
assert len(ufos) == len(self.layerNames)
- glyphSets = self.preprocess(ufos)
+ self.glyphSets = self.preprocess(ufos)
- for ufo, glyphSet, layerName in zip(ufos, glyphSets, self.layerNames):
+ for ufo, glyphSet, layerName in zip(ufos, self.glyphSets, self.layerNames):
yield self.compile_one(ufo, glyphSet, layerName)
def compile_one(self, ufo, glyphSet, layerName):
@@ -78,11 +78,6 @@ def compileOutlines(self, ufo, glyphSet, layerName=None):
outlineCompiler = self.outlineCompilerClass(ufo, glyphSet=glyphSet, **kwargs)
return outlineCompiler.compile()
- def compile_designspace(self, designSpaceDoc):
- ufos = self._pre_compile_designspace(designSpaceDoc)
- ttfs = self.compile(ufos)
- return self._post_compile_designspace(designSpaceDoc, ttfs)
-
def _merge(self, designSpaceDoc, excludeVariationTables):
return varLib.build_many(
designSpaceDoc,
diff --git a/Lib/ufo2ft/_compilers/otfCompiler.py b/Lib/ufo2ft/_compilers/otfCompiler.py
index 0e3acfb8..6c76bce7 100644
--- a/Lib/ufo2ft/_compilers/otfCompiler.py
+++ b/Lib/ufo2ft/_compilers/otfCompiler.py
@@ -33,4 +33,5 @@ def postprocess(self, font, ufo, glyphSet):
kwargs = prune_unknown_kwargs(self.__dict__, postProcessor.process)
kwargs["optimizeCFF"] = self.optimizeCFF >= CFFOptimization.SUBROUTINIZE
font = postProcessor.process(**kwargs)
+ self._glyphSet = glyphSet
return font
diff --git a/Lib/ufo2ft/_compilers/ttfCompiler.py b/Lib/ufo2ft/_compilers/ttfCompiler.py
index a5c32f4c..05bd2653 100644
--- a/Lib/ufo2ft/_compilers/ttfCompiler.py
+++ b/Lib/ufo2ft/_compilers/ttfCompiler.py
@@ -21,7 +21,7 @@ class TTFCompiler(BaseCompiler):
dropImpliedOnCurves: bool = False
allQuadratic: bool = True
- def compileOutlines(self, ufo, glyphSet, layerName=None):
+ def compileOutlines(self, ufo, glyphSet):
kwargs = prune_unknown_kwargs(self.__dict__, self.outlineCompilerClass)
kwargs["glyphDataFormat"] = 0 if self.allQuadratic else 1
outlineCompiler = self.outlineCompilerClass(ufo, glyphSet=glyphSet, **kwargs)
diff --git a/Lib/ufo2ft/_compilers/variableCFF2sCompiler.py b/Lib/ufo2ft/_compilers/variableCFF2sCompiler.py
index 83c8044e..b79cb543 100644
--- a/Lib/ufo2ft/_compilers/variableCFF2sCompiler.py
+++ b/Lib/ufo2ft/_compilers/variableCFF2sCompiler.py
@@ -17,3 +17,4 @@ class VariableCFF2sCompiler(InterpolatableOTFCompiler):
cffVersion: int = 2
optimizeCFF: CFFOptimization = CFFOptimization.SPECIALIZE
excludeVariationTables: tuple = ()
+ variableFeatures: bool = True
diff --git a/Lib/ufo2ft/_compilers/variableTTFsCompiler.py b/Lib/ufo2ft/_compilers/variableTTFsCompiler.py
index 50ca7ac9..e9e75b7c 100644
--- a/Lib/ufo2ft/_compilers/variableTTFsCompiler.py
+++ b/Lib/ufo2ft/_compilers/variableTTFsCompiler.py
@@ -21,4 +21,4 @@ class VariableTTFsCompiler(InterpolatableTTFCompiler):
autoUseMyMetrics: bool = True
dropImpliedOnCurves: bool = False
allQuadratic: bool = True
- pass
+ variableFeatures: bool = True
diff --git a/Lib/ufo2ft/featureCompiler.py b/Lib/ufo2ft/featureCompiler.py
index 788e21da..e1c6bc98 100644
--- a/Lib/ufo2ft/featureCompiler.py
+++ b/Lib/ufo2ft/featureCompiler.py
@@ -9,6 +9,7 @@
from tempfile import NamedTemporaryFile
from fontTools import mtiLib
+from fontTools.designspaceLib import DesignSpaceDocument, SourceDescriptor
from fontTools.feaLib.builder import addOpenTypeFeaturesFromString
from fontTools.feaLib.error import FeatureLibError, IncludedFeaNotFound
from fontTools.feaLib.parser import Parser
@@ -102,6 +103,10 @@ def __init__(self, ufo, ttFont=None, glyphSet=None, extraSubstitutions=None):
glyphOrder = ttFont.getGlyphOrder()
if glyphSet is not None:
+ if set(glyphOrder) != set(glyphSet.keys()):
+ print("Glyph order incompatible")
+ print("In UFO but not in font:", set(glyphSet.keys()) - set(glyphOrder))
+ print("In font but not in UFO:", set(glyphOrder) - set(glyphSet.keys()))
assert set(glyphOrder) == set(glyphSet.keys())
else:
glyphSet = ufo
@@ -407,3 +412,56 @@ def warn_about_miscased_insertion_markers(
text,
pattern_case.pattern,
)
+
+
+class VariableFeatureCompiler(FeatureCompiler):
+ """Generate a variable feature file and compile OpenType tables from a
+ designspace file.
+ """
+
+ def __init__(
+ self,
+ ufo,
+ designspace,
+ ttFont=None,
+ glyphSet=None,
+ featureWriters=None,
+ **kwargs,
+ ):
+ self.designspace = designspace
+ super().__init__(ufo, ttFont, glyphSet, featureWriters, **kwargs)
+
+ def setupFeatures(self):
+ if self.featureWriters:
+ featureFile = parseLayoutFeatures(self.ufo)
+
+ for writer in self.featureWriters:
+ writer.write(self.designspace, featureFile, compiler=self)
+
+ # stringify AST to get correct line numbers in error messages
+ self.features = featureFile.asFea()
+ else:
+ # no featureWriters, simply read existing features' text
+ self.features = self.ufo.features.text or ""
+
+
+def _featuresCompatible(designSpaceDoc: DesignSpaceDocument) -> bool:
+ """Returns whether the features of the individual source UFOs are the same.
+
+ NOTE: Only compares the feature file text inside the source UFO and does not
+ follow imports. This will suffice as long as no external feature file is
+ using variable syntax and all sources are stored n the same parent folder
+ (so the same includes point to the same files).
+ """
+
+ assert all(hasattr(source.font, "features") for source in designSpaceDoc.sources)
+
+ def transform(f: SourceDescriptor) -> str:
+ # Strip comments
+ text = re.sub("(?m)#.*$", "", f.font.features.text or "")
+ # Strip extraneous whitespace
+ text = re.sub(r"\s+", " ", text)
+ return text
+
+ first = transform(designSpaceDoc.sources[0])
+ return all(transform(s) == first for s in designSpaceDoc.sources[1:])
diff --git a/Lib/ufo2ft/featureWriters/baseFeatureWriter.py b/Lib/ufo2ft/featureWriters/baseFeatureWriter.py
index b69f834e..32a15e4b 100644
--- a/Lib/ufo2ft/featureWriters/baseFeatureWriter.py
+++ b/Lib/ufo2ft/featureWriters/baseFeatureWriter.py
@@ -2,10 +2,19 @@
from collections import OrderedDict, namedtuple
from types import SimpleNamespace
+from fontTools.designspaceLib import DesignSpaceDocument
+from fontTools.feaLib.variableScalar import VariableScalar
+from fontTools.misc.fixedTools import otRound
+
from ufo2ft.constants import OPENTYPE_CATEGORIES_KEY
from ufo2ft.errors import InvalidFeaturesData
from ufo2ft.featureWriters import ast
-from ufo2ft.util import unicodeScriptExtensions
+from ufo2ft.util import (
+ collapse_varscalar,
+ get_userspace_location,
+ quantize,
+ unicodeScriptExtensions,
+)
INSERT_FEATURE_MARKER = r"\s*# Automatic Code.*"
@@ -107,6 +116,7 @@ def setContext(self, font, feaFile, compiler=None):
todo=todo,
insertComments=insertComments,
existingFeatures=existing,
+ isVariable=isinstance(font, DesignSpaceDocument),
)
return self.context
@@ -312,6 +322,8 @@ def compileGSUB(self):
from ufo2ft.util import compileGSUB
compiler = self.context.compiler
+ fvar = None
+ feafile = self.context.feaFile
if compiler is not None:
# The result is cached in the compiler instance, so if another
# writer requests one it is not compiled again.
@@ -319,12 +331,13 @@ def compileGSUB(self):
return compiler._gsub
glyphOrder = compiler.ttFont.getGlyphOrder()
+ fvar = compiler.ttFont.get("fvar")
else:
# the 'real' glyph order doesn't matter because the table is not
# compiled to binary, only the glyph names are used
glyphOrder = sorted(self.context.font.keys())
- gsub = compileGSUB(self.context.feaFile, glyphOrder)
+ gsub = compileGSUB(feafile, glyphOrder, fvar=fvar)
if compiler and not hasattr(compiler, "_gsub"):
compiler._gsub = gsub
@@ -347,6 +360,10 @@ def getOpenTypeCategories(self):
set(),
)
openTypeCategories = font.lib.get(OPENTYPE_CATEGORIES_KEY, {})
+ # Handle case where we are a variable feature writer
+ if not openTypeCategories and isinstance(font, DesignSpaceDocument):
+ font = font.sources[0].font
+ openTypeCategories = font.lib.get(OPENTYPE_CATEGORIES_KEY, {})
for glyphName, category in openTypeCategories.items():
if category == "unassigned":
@@ -414,6 +431,10 @@ def guessFontScripts(self):
feaFile = self.context.feaFile
single_scripts = set()
+ # If we're dealing with a Designspace, look at the default source.
+ if hasattr(font, "findDefault"):
+ font = font.findDefault().font
+
# First, detect scripts from the codepoints.
for glyph in font:
if glyph.name not in glyphSet or glyph.unicodes is None:
@@ -428,3 +449,41 @@ def guessFontScripts(self):
single_scripts.update(feaScripts.keys())
return single_scripts
+
+ def _getAnchor(self, glyphName, anchorName, anchor=None):
+ if self.context.isVariable:
+ designspace = self.context.font
+ x_value = VariableScalar()
+ y_value = VariableScalar()
+ found = False
+ for source in designspace.sources:
+ if glyphName not in source.font:
+ return None
+ glyph = source.font[glyphName]
+ for anchor in glyph.anchors:
+ if anchor.name == anchorName:
+ location = get_userspace_location(designspace, source.location)
+ x_value.add_value(location, otRound(anchor.x))
+ y_value.add_value(location, otRound(anchor.y))
+ found = True
+ if not found:
+ return None
+ x, y = collapse_varscalar(x_value), collapse_varscalar(y_value)
+ else:
+ if anchor is None:
+ if glyphName not in self.context.font:
+ return None
+ glyph = self.context.font[glyphName]
+ anchors = [
+ anchor for anchor in glyph.anchors if anchor.name == anchorName
+ ]
+ if not anchors:
+ return None
+ anchor = anchors[0]
+
+ 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
diff --git a/Lib/ufo2ft/featureWriters/cursFeatureWriter.py b/Lib/ufo2ft/featureWriters/cursFeatureWriter.py
index 020b4d8a..70f4ae4d 100644
--- a/Lib/ufo2ft/featureWriters/cursFeatureWriter.py
+++ b/Lib/ufo2ft/featureWriters/cursFeatureWriter.py
@@ -1,7 +1,5 @@
-from fontTools.misc.fixedTools import otRound
-
from ufo2ft.featureWriters import BaseFeatureWriter, ast
-from ufo2ft.util import classifyGlyphs, unicodeScriptDirection
+from ufo2ft.util import classifyGlyphs, otRoundIgnoringVariable, unicodeScriptDirection
class CursFeatureWriter(BaseFeatureWriter):
@@ -90,19 +88,28 @@ def _makeCursiveLookup(self, glyphs, direction=None):
return lookup
+ def _getAnchors(self, glyphName, glyph=None):
+ entryAnchor = None
+ exitAnchor = None
+ entryAnchorXY = self._getAnchor(glyphName, "entry")
+ exitAnchorXY = self._getAnchor(glyphName, "exit")
+ if entryAnchorXY:
+ entryAnchor = ast.Anchor(
+ x=otRoundIgnoringVariable(entryAnchorXY[0]),
+ y=otRoundIgnoringVariable(entryAnchorXY[1]),
+ )
+ if exitAnchorXY:
+ exitAnchor = ast.Anchor(
+ x=otRoundIgnoringVariable(exitAnchorXY[0]),
+ y=otRoundIgnoringVariable(exitAnchorXY[1]),
+ )
+ return entryAnchor, exitAnchor
+
def _makeCursiveStatements(self, glyphs):
cursiveAnchors = dict()
statements = []
for glyph in glyphs:
- entryAnchor = exitAnchor = None
- for anchor in glyph.anchors:
- if entryAnchor and exitAnchor:
- break
- if anchor.name == "entry":
- entryAnchor = ast.Anchor(x=otRound(anchor.x), y=otRound(anchor.y))
- elif anchor.name == "exit":
- exitAnchor = ast.Anchor(x=otRound(anchor.x), y=otRound(anchor.y))
-
+ entryAnchor, exitAnchor = self._getAnchors(glyph.name, glyph)
# A glyph can have only one of the cursive anchors (e.g. if it
# attaches on one side only)
if entryAnchor or exitAnchor:
diff --git a/Lib/ufo2ft/featureWriters/gdefFeatureWriter.py b/Lib/ufo2ft/featureWriters/gdefFeatureWriter.py
index 14527590..fcd41f19 100644
--- a/Lib/ufo2ft/featureWriters/gdefFeatureWriter.py
+++ b/Lib/ufo2ft/featureWriters/gdefFeatureWriter.py
@@ -1,3 +1,5 @@
+from fontTools.misc.fixedTools import otRound
+
from ufo2ft.featureWriters import BaseFeatureWriter, ast
@@ -55,16 +57,21 @@ def _getLigatureCarets(self):
and anchor.name.startswith("caret_")
and anchor.x is not None
):
- glyphCarets.add(round(anchor.x))
+ glyphCarets.add(self._getAnchor(glyphName, anchor.name)[0])
elif (
anchor.name
and anchor.name.startswith("vcaret_")
and anchor.y is not None
):
- glyphCarets.add(round(anchor.y))
+ glyphCarets.add(self._getAnchor(glyphName, anchor.name)[1])
if glyphCarets:
- carets[glyphName] = sorted(glyphCarets)
+ if self.context.isVariable:
+ carets[glyphName] = sorted(
+ glyphCarets, key=lambda caret: list(caret.values.values())[0]
+ )
+ else:
+ carets[glyphName] = [otRound(c) for c in sorted(glyphCarets)]
return carets
diff --git a/Lib/ufo2ft/featureWriters/kernFeatureWriter.py b/Lib/ufo2ft/featureWriters/kernFeatureWriter.py
index 0ce7c7e8..0713bb8f 100644
--- a/Lib/ufo2ft/featureWriters/kernFeatureWriter.py
+++ b/Lib/ufo2ft/featureWriters/kernFeatureWriter.py
@@ -4,9 +4,13 @@
import logging
from dataclasses import dataclass
from types import SimpleNamespace
-from typing import Iterator, Mapping
+from typing import Any, Iterator, Mapping
from fontTools import unicodedata
+from fontTools.designspaceLib import DesignSpaceDocument
+from fontTools.feaLib.variableScalar import Location as VariableScalarLocation
+from fontTools.feaLib.variableScalar import VariableScalar
+from fontTools.ufoLib.kerning import lookupKerningValue
from fontTools.unicodedata import script_horizontal_direction
from ufo2ft.constants import COMMON_SCRIPT, INDIC_SCRIPTS, USE_SCRIPTS
@@ -14,7 +18,9 @@
from ufo2ft.util import (
DFLT_SCRIPTS,
classifyGlyphs,
+ collapse_varscalar,
describe_ufo,
+ get_userspace_location,
quantize,
unicodeScriptExtensions,
)
@@ -60,7 +66,7 @@ class KerningPair:
side1: str | tuple[str, ...]
side2: str | tuple[str, ...]
- value: float
+ value: float | VariableScalar
def __lt__(self, other: KerningPair) -> bool:
if not isinstance(other, KerningPair):
@@ -205,20 +211,25 @@ def setContext(self, font, feaFile, compiler=None):
ctx.glyphSet = self.getOrderedGlyphSet()
# Unless we use the legacy append mode (which ignores insertion
- # markers), if the font contains kerning and the feaFile contains `kern`
- # or `dist` feature blocks, but we have no insertion markers (or they
- # were misspelt and ignored), warn the user that the kerning blocks in
- # the feaFile take precedence and other kerning is dropped.
+ # markers), if the font (Designspace: default source) contains kerning
+ # and the feaFile contains `kern` or `dist` feature blocks, but we have
+ # no insertion markers (or they were misspelt and ignored), warn the
+ # user that the kerning blocks in the feaFile take precedence and other
+ # kerning is dropped.
+ if hasattr(font, "findDefault"):
+ default_source = font.findDefault().font
+ else:
+ default_source = font
if (
self.mode == "skip"
- and font.kerning
+ and default_source.kerning
and ctx.existingFeatures & self.features
and not ctx.insertComments
):
LOGGER.warning(
"%s: font has kerning, but also manually written kerning features "
"without an insertion comment. Dropping the former.",
- describe_ufo(font),
+ describe_ufo(default_source),
)
# Remember which languages are defined for which OT tag, as all
@@ -299,41 +310,96 @@ def getKerningData(self):
side1Classes={}, side2Classes={}, classDefs={}, pairs=pairs
)
- def getKerningGroups(self):
- font = self.context.font
+ def getKerningGroups(
+ self,
+ ) -> tuple[Mapping[str, tuple[str, ...]], Mapping[str, tuple[str, ...]]]:
allGlyphs = self.context.glyphSet
- side1Groups = {}
- side2Groups = {}
- side1Membership = {}
- side2Membership = {}
- for name, members in font.groups.items():
- # prune non-existent or skipped glyphs
- members = {g for g in members if g in allGlyphs}
- if not members:
+
+ side1Groups: dict[str, tuple[str, ...]] = {}
+ side1Membership: dict[str, str] = {}
+ side2Groups: dict[str, tuple[str, ...]] = {}
+ side2Membership: dict[str, str] = {}
+
+ if isinstance(self.context.font, DesignSpaceDocument):
+ fonts = [source.font for source in self.context.font.sources]
+ else:
+ fonts = [self.context.font]
+
+ for font in fonts:
+ assert font is not None
+ for name, members in font.groups.items():
+ # prune non-existent or skipped glyphs
+ members = {g for g in members if g in allGlyphs}
# skip empty groups
- continue
- # skip groups without UFO3 public.kern{1,2} prefix
- if name.startswith(SIDE1_PREFIX):
- side1Groups[name] = tuple(sorted(members))
- name_truncated = name[len(SIDE1_PREFIX) :]
- for member in members:
- side1Membership[member] = name_truncated
- elif name.startswith(SIDE2_PREFIX):
- side2Groups[name] = tuple(sorted(members))
- name_truncated = name[len(SIDE2_PREFIX) :]
- for member in members:
- side2Membership[member] = name_truncated
+ if not members:
+ continue
+ # skip groups without UFO3 public.kern{1,2} prefix
+ if name.startswith(SIDE1_PREFIX):
+ name_truncated = name[len(SIDE1_PREFIX) :]
+ known_members = members.intersection(side1Membership.keys())
+ if known_members:
+ for glyph_name in known_members:
+ original_name_truncated = side1Membership[glyph_name]
+ if name_truncated != original_name_truncated:
+ log_regrouped_glyph(
+ "first",
+ name,
+ original_name_truncated,
+ font,
+ glyph_name,
+ )
+ # Skip the whole group definition if there is any
+ # overlap problem.
+ continue
+ group = side1Groups.get(name)
+ if group is None:
+ side1Groups[name] = tuple(sorted(members))
+ for member in members:
+ side1Membership[member] = name_truncated
+ elif set(group) != members:
+ log_redefined_group("left", name, group, font, members)
+ elif name.startswith(SIDE2_PREFIX):
+ name_truncated = name[len(SIDE2_PREFIX) :]
+ known_members = members.intersection(side2Membership.keys())
+ if known_members:
+ for glyph_name in known_members:
+ original_name_truncated = side2Membership[glyph_name]
+ if name_truncated != original_name_truncated:
+ log_regrouped_glyph(
+ "second",
+ name,
+ original_name_truncated,
+ font,
+ glyph_name,
+ )
+ # Skip the whole group definition if there is any
+ # overlap problem.
+ continue
+ group = side2Groups.get(name)
+ if group is None:
+ side2Groups[name] = tuple(sorted(members))
+ for member in members:
+ side2Membership[member] = name_truncated
+ elif set(group) != members:
+ log_redefined_group("right", name, group, font, members)
self.context.side1Membership = side1Membership
self.context.side2Membership = side2Membership
return side1Groups, side2Groups
- def getKerningPairs(self, side1Classes, side2Classes):
+ def getKerningPairs(
+ self,
+ side1Classes: Mapping[str, tuple[str, ...]],
+ side2Classes: Mapping[str, tuple[str, ...]],
+ ) -> list[KerningPair]:
+ if self.context.isVariable:
+ return self.getVariableKerningPairs(side1Classes, side2Classes)
+
glyphSet = self.context.glyphSet
font = self.context.font
kerning = font.kerning
quantization = self.options.quantization
- kerning = font.kerning
+ kerning: Mapping[tuple[str, str], float] = font.kerning
result = []
for (side1, side2), value in kerning.items():
firstIsClass, secondIsClass = (side1 in side1Classes, side2 in side2Classes)
@@ -356,6 +422,122 @@ def getKerningPairs(self, side1Classes, side2Classes):
return result
+ def getVariableKerningPairs(
+ self,
+ side1Classes: Mapping[str, tuple[str, ...]],
+ side2Classes: Mapping[str, tuple[str, ...]],
+ ) -> list[KerningPair]:
+ designspace: DesignSpaceDocument = self.context.font
+ glyphSet = self.context.glyphSet
+ quantization = self.options.quantization
+
+ # Gather utility variables for faster kerning lookups.
+ # TODO: Do we construct these in code elsewhere?
+ assert not (set(side1Classes) & set(side2Classes))
+ unified_groups = {**side1Classes, **side2Classes}
+
+ glyphToFirstGroup = {
+ glyph_name: group_name # TODO: Is this overwrite safe? User input is adversarial
+ for group_name, glyphs in side1Classes.items()
+ for glyph_name in glyphs
+ }
+ glyphToSecondGroup = {
+ glyph_name: group_name
+ for group_name, glyphs in side2Classes.items()
+ for glyph_name in glyphs
+ }
+
+ # Collate every kerning pair in the designspace, as even UFOs that
+ # provide no entry for the pair must contribute a value at their
+ # source's location in the VariableScalar.
+ # NOTE: This is required as the DS+UFO kerning model and the OpenType
+ # variation model handle the absence of a kerning value at a
+ # given location differently:
+ # - DS+UFO:
+ # If the missing pair excepts another pair, take its value;
+ # Otherwise, take a value of 0.
+ # - OpenType:
+ # Always interpolate from other locations, ignoring more
+ # general pairs that this one excepts.
+ # See discussion: https://github.com/googlefonts/ufo2ft/pull/635
+ all_pairs: set[tuple[str, str]] = set()
+ for source in designspace.sources:
+ if source.layerName is not None:
+ continue
+ assert source.font is not None
+ all_pairs |= set(source.font.kerning)
+
+ kerning_pairs_in_progress: dict[
+ tuple[str | tuple[str], str | tuple[str]], VariableScalar
+ ] = {}
+ for source in designspace.sources:
+ # Skip sparse sources, because they can have no kerning.
+ if source.layerName is not None:
+ continue
+ assert source.font is not None
+
+ location = VariableScalarLocation(
+ get_userspace_location(designspace, source.location)
+ )
+
+ kerning: Mapping[tuple[str, str], float] = source.font.kerning
+ for pair in all_pairs:
+ side1, side2 = pair
+ firstIsClass = side1 in side1Classes
+ secondIsClass = side2 in side2Classes
+
+ # Filter out pairs that reference missing groups or glyphs.
+ # TODO: Can we do this outside of the loop? We know the pairs already.
+ if not firstIsClass and side1 not in glyphSet:
+ continue
+ if not secondIsClass and side2 not in glyphSet:
+ continue
+
+ # Get the kerning value for this source and quantize, following
+ # the DS+UFO semantics described above.
+ value = quantize(
+ lookupKerningValue(
+ pair,
+ kerning,
+ unified_groups,
+ glyphToFirstGroup=glyphToFirstGroup,
+ glyphToSecondGroup=glyphToSecondGroup,
+ ),
+ quantization,
+ )
+
+ if firstIsClass:
+ side1 = side1Classes[side1]
+ if secondIsClass:
+ side2 = side2Classes[side2]
+
+ # TODO: Can we instantiate these outside of the loop? We know the pairs already.
+ var_scalar = kerning_pairs_in_progress.setdefault(
+ (side1, side2), VariableScalar()
+ )
+ # NOTE: Avoid using .add_value because it instantiates a new
+ # VariableScalarLocation on each call.
+ var_scalar.values[location] = value
+
+ # We may need to provide a default location value to the variation
+ # model, find out where that is.
+ default_source = designspace.findDefault()
+ assert default_source is not None
+ default_location = VariableScalarLocation(
+ get_userspace_location(designspace, default_source.location)
+ )
+
+ result = []
+ for (side1, side2), value in kerning_pairs_in_progress.items():
+ # TODO: Should we interpolate a default value if it's not in the
+ # sources, rather than inserting a zero? What would varLib do?
+ if default_location not in value.values:
+ value.values[default_location] = 0
+ value = collapse_varscalar(value)
+ result.append(KerningPair(side1, side2, value))
+
+ return result
+
def _makePairPosRule(self, pair, side1Classes, side2Classes, rtl=False):
enumerated = pair.firstIsClass ^ pair.secondIsClass
valuerecord = ast.ValueRecord(
@@ -382,6 +564,18 @@ def _makePairPosRule(self, pair, side1Classes, side2Classes, rtl=False):
enumerated=enumerated,
)
+ def _filterSpacingMarks(self, marks):
+ if self.context.isVariable:
+ spacing = []
+ for mark in marks:
+ if all(
+ source.font[mark].width != 0 for source in self.context.font.sources
+ ):
+ spacing.append(mark)
+ return spacing
+
+ return [mark for mark in marks if self.context.font[mark].width != 0]
+
def _makeKerningLookup(self, name, ignoreMarks=True):
lookup = ast.LookupBlock(name)
if ignoreMarks and self.options.ignoreMarks:
@@ -392,7 +586,7 @@ def _makeKerningLookup(self, name, ignoreMarks=True):
spacing = []
if marks:
- spacing = [mark for mark in marks if self.context.font[mark].width != 0]
+ spacing = self._filterSpacingMarks(marks)
if not spacing:
# Simple case, there are no spacing ("Spacing Combining") marks,
# do what we've always done.
@@ -816,3 +1010,30 @@ def addClassDefinition(
classNames.add(className)
classDef = ast.makeGlyphClassDefinition(className, group)
classes[group] = classDefs[className] = classDef
+
+
+def log_redefined_group(
+ side: str, name: str, group: tuple[str, ...], font: Any, members: set[str]
+) -> None:
+ LOGGER.warning(
+ "incompatible %s groups: %s was previously %s, %s tried to make it %s",
+ side,
+ name,
+ sorted(group),
+ font,
+ sorted(members),
+ )
+
+
+def log_regrouped_glyph(
+ side: str, name: str, original_name: str, font: Any, member: str
+) -> None:
+ LOGGER.warning(
+ "incompatible %s groups: %s tries to put glyph %s in group %s, but it's already in %s, "
+ "discarding",
+ side,
+ font,
+ member,
+ name,
+ original_name,
+ )
diff --git a/Lib/ufo2ft/featureWriters/markFeatureWriter.py b/Lib/ufo2ft/featureWriters/markFeatureWriter.py
index d8e620ad..22fbc16b 100644
--- a/Lib/ufo2ft/featureWriters/markFeatureWriter.py
+++ b/Lib/ufo2ft/featureWriters/markFeatureWriter.py
@@ -3,13 +3,11 @@
from collections import OrderedDict, defaultdict
from functools import partial
-from fontTools.misc.fixedTools import otRound
-
from ufo2ft.constants import INDIC_SCRIPTS, USE_SCRIPTS
from ufo2ft.featureWriters import BaseFeatureWriter, ast
from ufo2ft.util import (
classifyGlyphs,
- quantize,
+ otRoundIgnoringVariable,
unicodeInScripts,
unicodeScriptExtensions,
)
@@ -34,7 +32,13 @@ def _filterMarks(self, include):
def _marksAsAST(self):
return [
- (ast.Anchor(x=otRound(anchor.x), y=otRound(anchor.y)), anchor.markClass)
+ (
+ ast.Anchor(
+ x=otRoundIgnoringVariable(anchor.x),
+ y=otRoundIgnoringVariable(anchor.y),
+ ),
+ anchor.markClass,
+ )
for anchor in sorted(self.marks, key=lambda a: a.name)
]
@@ -78,7 +82,13 @@ def _filterMarks(self, include):
def _marksAsAST(self):
return [
[
- (ast.Anchor(x=otRound(anchor.x), y=otRound(anchor.y)), anchor.markClass)
+ (
+ ast.Anchor(
+ x=otRoundIgnoringVariable(anchor.x),
+ y=otRoundIgnoringVariable(anchor.y),
+ ),
+ anchor.markClass,
+ )
for anchor in sorted(component, key=lambda a: a.name)
]
for component in self.marks
@@ -334,8 +344,7 @@ def _getAnchorLists(self):
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)
+ x, y = self._getAnchor(glyphName, anchorName, anchor=anchor)
a = self.NamedAnchor(name=anchorName, x=x, y=y)
anchorDict[anchorName] = a
if anchorDict:
@@ -411,7 +420,7 @@ def _makeMarkClassDefinitions(self):
return newDefs
def _defineMarkClass(self, glyphName, x, y, className, markClasses):
- anchor = ast.Anchor(x=otRound(x), y=otRound(y))
+ anchor = ast.Anchor(x=otRoundIgnoringVariable(x), y=otRoundIgnoringVariable(y))
markClass = markClasses.get(className)
if markClass is None:
markClass = ast.MarkClass(className)
diff --git a/Lib/ufo2ft/util.py b/Lib/ufo2ft/util.py
index 48d56a79..d034f0bf 100644
--- a/Lib/ufo2ft/util.py
+++ b/Lib/ufo2ft/util.py
@@ -243,12 +243,14 @@ def makeUnicodeToGlyphNameMapping(font, glyphOrder=None):
return mapping
-def compileGSUB(featureFile, glyphOrder):
+def compileGSUB(featureFile, glyphOrder, fvar=None):
"""Compile and return a GSUB table from `featureFile` (feaLib
FeatureFile), using the given `glyphOrder` (list of glyph names).
"""
font = ttLib.TTFont()
font.setGlyphOrder(glyphOrder)
+ if fvar:
+ font["fvar"] = fvar
addOpenTypeFeatures(font, featureFile, tables={"GSUB"})
return font.get("GSUB")
@@ -549,9 +551,18 @@ def _loadPluginFromString(spec, moduleName, isValidFunc):
def quantize(number, factor):
"""Round to a multiple of the given parameter"""
+ if not isinstance(number, (float, int)):
+ # Some kind of variable scalar
+ return number
return factor * otRound(number / factor)
+def otRoundIgnoringVariable(number):
+ if not isinstance(number, (float, int)):
+ return number
+ return otRound(number)
+
+
def init_kwargs(kwargs, defaults):
"""Initialise kwargs default values.
@@ -673,3 +684,18 @@ def colrClipBoxQuantization(ufo: Any) -> int:
"""
upem = getAttrWithFallback(ufo.info, "unitsPerEm")
return int(round(upem / 10, -1))
+
+
+def get_userspace_location(designspace, location):
+ """Map a location from designspace to userspace across all axes."""
+ location_user = designspace.map_backward(location)
+ return {designspace.getAxis(k).tag: v for k, v in location_user.items()}
+
+
+def collapse_varscalar(varscalar, threshold=0):
+ """Collapse a variable scalar to a plain scalar if all values are similar"""
+ # This should eventually be a method on the VariableScalar object
+ values = list(varscalar.values.values())
+ if not any(abs(v - values[0]) > threshold for v in values[1:]):
+ return list(varscalar.values.values())[0]
+ return varscalar
diff --git a/setup.py b/setup.py
index fa193fd4..10f97610 100644
--- a/setup.py
+++ b/setup.py
@@ -29,7 +29,7 @@
setup_requires=pytest_runner + wheel + ["setuptools_scm"],
tests_require=["pytest>=2.8"],
install_requires=[
- "fonttools[ufo]>=4.44.3",
+ "fonttools[ufo]>=4.46.0",
"cffsubr>=0.2.8",
"booleanOperations>=0.9.0",
],
diff --git a/tests/data/TestVarfea-Bold.ufo/fontinfo.plist b/tests/data/TestVarfea-Bold.ufo/fontinfo.plist
new file mode 100644
index 00000000..d6b5d796
--- /dev/null
+++ b/tests/data/TestVarfea-Bold.ufo/fontinfo.plist
@@ -0,0 +1,36 @@
+
+
+
+
+ ascender
+ 600
+ capHeight
+ 700
+ descender
+ -400
+ familyName
+ TestVarfea
+ italicAngle
+ 0
+ openTypeHeadCreated
+ 2022/07/26 14:49:29
+ openTypeOS2Type
+
+ 3
+
+ postscriptUnderlinePosition
+ -100
+ postscriptUnderlineThickness
+ 50
+ styleName
+ Bold
+ unitsPerEm
+ 1000
+ versionMajor
+ 1
+ versionMinor
+ 0
+ xHeight
+ 500
+
+
diff --git a/tests/data/TestVarfea-Bold.ufo/glyphs/alef-ar.fina.glif b/tests/data/TestVarfea-Bold.ufo/glyphs/alef-ar.fina.glif
new file mode 100644
index 00000000..873945f8
--- /dev/null
+++ b/tests/data/TestVarfea-Bold.ufo/glyphs/alef-ar.fina.glif
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ com.schriftgestaltung.Glyphs.lastChange
+ 2023/11/20 16:51:14
+
+
+
diff --git a/tests/data/TestVarfea-Bold.ufo/glyphs/contents.plist b/tests/data/TestVarfea-Bold.ufo/glyphs/contents.plist
new file mode 100644
index 00000000..4572eaba
--- /dev/null
+++ b/tests/data/TestVarfea-Bold.ufo/glyphs/contents.plist
@@ -0,0 +1,16 @@
+
+
+
+
+ alef-ar.fina
+ alef-ar.fina.glif
+ dotabove-ar
+ dotabove-ar.glif
+ peh-ar.init
+ peh-ar.init.glif
+ peh-ar.init.BRACKET.varAlt01
+ peh-ar.init.B_R_A_C_K_E_T_.varA_lt01.glif
+ space
+ space.glif
+
+
diff --git a/tests/data/TestVarfea-Bold.ufo/glyphs/dotabove-ar.glif b/tests/data/TestVarfea-Bold.ufo/glyphs/dotabove-ar.glif
new file mode 100644
index 00000000..05dde66a
--- /dev/null
+++ b/tests/data/TestVarfea-Bold.ufo/glyphs/dotabove-ar.glif
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ com.schriftgestaltung.Glyphs.lastChange
+ 2023/11/20 16:50:50
+ com.schriftgestaltung.Glyphs.originalWidth
+ 300
+
+
+
diff --git a/tests/data/TestVarfea-Bold.ufo/glyphs/layerinfo.plist b/tests/data/TestVarfea-Bold.ufo/glyphs/layerinfo.plist
new file mode 100644
index 00000000..82fbc0d8
--- /dev/null
+++ b/tests/data/TestVarfea-Bold.ufo/glyphs/layerinfo.plist
@@ -0,0 +1,19 @@
+
+
+
+
+ lib
+
+ com.schriftgestaltung.layerId
+ B1C208F5-A14F-4863-B7E7-1D4BAD4C88B2
+ com.schriftgestaltung.layerOrderInGlyph.alef-ar.fina
+ 1
+ com.schriftgestaltung.layerOrderInGlyph.dotabove-ar
+ 1
+ com.schriftgestaltung.layerOrderInGlyph.peh-ar.init
+ 1
+ com.schriftgestaltung.layerOrderInGlyph.space
+ 1
+
+
+
diff --git a/tests/data/TestVarfea-Bold.ufo/glyphs/peh-ar.init.B_R_A_C_K_E_T_.varA_lt01.glif b/tests/data/TestVarfea-Bold.ufo/glyphs/peh-ar.init.B_R_A_C_K_E_T_.varA_lt01.glif
new file mode 100644
index 00000000..82cc961e
--- /dev/null
+++ b/tests/data/TestVarfea-Bold.ufo/glyphs/peh-ar.init.B_R_A_C_K_E_T_.varA_lt01.glif
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ com.schriftgestaltung.Glyphs._originalLayerName
+ 26 Jul 22, 15:59
+ com.schriftgestaltung.Glyphs.lastChange
+ 2022/07/27 08:01:34
+
+
+
diff --git a/tests/data/TestVarfea-Bold.ufo/glyphs/peh-ar.init.glif b/tests/data/TestVarfea-Bold.ufo/glyphs/peh-ar.init.glif
new file mode 100644
index 00000000..a8e87507
--- /dev/null
+++ b/tests/data/TestVarfea-Bold.ufo/glyphs/peh-ar.init.glif
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ com.schriftgestaltung.Glyphs.lastChange
+ 2022/07/27 08:01:34
+
+
+
diff --git a/tests/data/TestVarfea-Bold.ufo/glyphs/space.glif b/tests/data/TestVarfea-Bold.ufo/glyphs/space.glif
new file mode 100644
index 00000000..fa922f49
--- /dev/null
+++ b/tests/data/TestVarfea-Bold.ufo/glyphs/space.glif
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+ com.schriftgestaltung.Glyphs.lastChange
+ 2022/07/26 15:00:45
+
+
+
diff --git a/tests/data/TestVarfea-Bold.ufo/kerning.plist b/tests/data/TestVarfea-Bold.ufo/kerning.plist
new file mode 100644
index 00000000..6bf0bd2a
--- /dev/null
+++ b/tests/data/TestVarfea-Bold.ufo/kerning.plist
@@ -0,0 +1,11 @@
+
+
+
+
+ alef-ar.fina
+
+ alef-ar.fina
+ 35
+
+
+
diff --git a/tests/data/TestVarfea-Bold.ufo/layercontents.plist b/tests/data/TestVarfea-Bold.ufo/layercontents.plist
new file mode 100644
index 00000000..b9c1a4f2
--- /dev/null
+++ b/tests/data/TestVarfea-Bold.ufo/layercontents.plist
@@ -0,0 +1,10 @@
+
+
+
+
+
+ public.default
+ glyphs
+
+
+
diff --git a/tests/data/TestVarfea-Bold.ufo/lib.plist b/tests/data/TestVarfea-Bold.ufo/lib.plist
new file mode 100644
index 00000000..d4c4a8cc
--- /dev/null
+++ b/tests/data/TestVarfea-Bold.ufo/lib.plist
@@ -0,0 +1,80 @@
+
+
+
+
+ GSCornerRadius
+ 15
+ GSOffsetHorizontal
+ -30
+ GSOffsetVertical
+ -25
+ com.github.googlei18n.ufo2ft.filters
+
+
+ name
+ eraseOpenCorners
+ namespace
+ glyphsLib.filters
+ pre
+
+
+
+ com.schriftgestaltung.appVersion
+ 3217
+ com.schriftgestaltung.customParameter.GSFont.DisplayStrings
+
+ اا
+ /dotabove-ar
+
+ com.schriftgestaltung.customParameter.GSFont.disablesAutomaticAlignment
+
+ com.schriftgestaltung.customParameter.GSFont.useNiceNames
+ 1
+ com.schriftgestaltung.customParameter.GSFontMaster.customValue
+ 0
+ com.schriftgestaltung.customParameter.GSFontMaster.customValue1
+ 0
+ com.schriftgestaltung.customParameter.GSFontMaster.customValue2
+ 0
+ com.schriftgestaltung.customParameter.GSFontMaster.customValue3
+ 0
+ com.schriftgestaltung.customParameter.GSFontMaster.iconName
+ Bold
+ com.schriftgestaltung.customParameter.GSFontMaster.weightValue
+ 1000
+ com.schriftgestaltung.customParameter.GSFontMaster.widthValue
+ 100
+ com.schriftgestaltung.fontMasterID
+ B1C208F5-A14F-4863-B7E7-1D4BAD4C88B2
+ com.schriftgestaltung.fontMasterOrder
+ 1
+ com.schriftgestaltung.keyboardIncrement
+ 1
+ com.schriftgestaltung.weight
+ Regular
+ com.schriftgestaltung.weightValue
+ 1000
+ com.schriftgestaltung.width
+ Regular
+ com.schriftgestaltung.widthValue
+ 100
+ public.glyphOrder
+
+ alef-ar.fina
+ peh-ar.init
+ space
+ dotabove-ar
+
+ public.postscriptNames
+
+ alef-ar.fina
+ uniFE8E
+ dotabove-ar
+ dotabovear
+ peh-ar.init
+ uniFB58
+ peh-ar.init.BRACKET.varAlt01
+ uniFB58.BRACKET.varAlt01
+
+
+
diff --git a/tests/data/TestVarfea-Bold.ufo/metainfo.plist b/tests/data/TestVarfea-Bold.ufo/metainfo.plist
new file mode 100644
index 00000000..7b8b34ac
--- /dev/null
+++ b/tests/data/TestVarfea-Bold.ufo/metainfo.plist
@@ -0,0 +1,10 @@
+
+
+
+
+ creator
+ com.github.fonttools.ufoLib
+ formatVersion
+ 3
+
+
diff --git a/tests/data/TestVarfea-Regular.ufo/fontinfo.plist b/tests/data/TestVarfea-Regular.ufo/fontinfo.plist
new file mode 100644
index 00000000..9f3eadf5
--- /dev/null
+++ b/tests/data/TestVarfea-Regular.ufo/fontinfo.plist
@@ -0,0 +1,48 @@
+
+
+
+
+ ascender
+ 600
+ capHeight
+ 0
+ descender
+ -400
+ familyName
+ TestVarfea
+ italicAngle
+ 0
+ openTypeHeadCreated
+ 2022/07/26 14:49:29
+ openTypeOS2Type
+
+ 3
+
+ postscriptBlueValues
+
+ -16
+ 0
+ 600
+ 616
+
+ postscriptOtherBlues
+
+ -416
+ -400
+
+ postscriptUnderlinePosition
+ -100
+ postscriptUnderlineThickness
+ 50
+ styleName
+ Regular
+ unitsPerEm
+ 1000
+ versionMajor
+ 1
+ versionMinor
+ 0
+ xHeight
+ 0
+
+
diff --git a/tests/data/TestVarfea-Regular.ufo/glyphs/alef-ar.fina.glif b/tests/data/TestVarfea-Regular.ufo/glyphs/alef-ar.fina.glif
new file mode 100644
index 00000000..dfd59957
--- /dev/null
+++ b/tests/data/TestVarfea-Regular.ufo/glyphs/alef-ar.fina.glif
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ com.schriftgestaltung.Glyphs.lastChange
+ 2023/11/20 16:51:14
+
+
+
diff --git a/tests/data/TestVarfea-Regular.ufo/glyphs/contents.plist b/tests/data/TestVarfea-Regular.ufo/glyphs/contents.plist
new file mode 100644
index 00000000..4572eaba
--- /dev/null
+++ b/tests/data/TestVarfea-Regular.ufo/glyphs/contents.plist
@@ -0,0 +1,16 @@
+
+
+
+
+ alef-ar.fina
+ alef-ar.fina.glif
+ dotabove-ar
+ dotabove-ar.glif
+ peh-ar.init
+ peh-ar.init.glif
+ peh-ar.init.BRACKET.varAlt01
+ peh-ar.init.B_R_A_C_K_E_T_.varA_lt01.glif
+ space
+ space.glif
+
+
diff --git a/tests/data/TestVarfea-Regular.ufo/glyphs/dotabove-ar.glif b/tests/data/TestVarfea-Regular.ufo/glyphs/dotabove-ar.glif
new file mode 100644
index 00000000..d9ec3589
--- /dev/null
+++ b/tests/data/TestVarfea-Regular.ufo/glyphs/dotabove-ar.glif
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ com.schriftgestaltung.Glyphs.lastChange
+ 2023/11/20 16:50:50
+ com.schriftgestaltung.Glyphs.originalWidth
+ 300
+
+
+
diff --git a/tests/data/TestVarfea-Regular.ufo/glyphs/layerinfo.plist b/tests/data/TestVarfea-Regular.ufo/glyphs/layerinfo.plist
new file mode 100644
index 00000000..e4d188f0
--- /dev/null
+++ b/tests/data/TestVarfea-Regular.ufo/glyphs/layerinfo.plist
@@ -0,0 +1,19 @@
+
+
+
+
+ lib
+
+ com.schriftgestaltung.layerId
+ m01
+ com.schriftgestaltung.layerOrderInGlyph.alef-ar.fina
+ 0
+ com.schriftgestaltung.layerOrderInGlyph.dotabove-ar
+ 0
+ com.schriftgestaltung.layerOrderInGlyph.peh-ar.init
+ 0
+ com.schriftgestaltung.layerOrderInGlyph.space
+ 0
+
+
+
diff --git a/tests/data/TestVarfea-Regular.ufo/glyphs/peh-ar.init.B_R_A_C_K_E_T_.varA_lt01.glif b/tests/data/TestVarfea-Regular.ufo/glyphs/peh-ar.init.B_R_A_C_K_E_T_.varA_lt01.glif
new file mode 100644
index 00000000..26c13c38
--- /dev/null
+++ b/tests/data/TestVarfea-Regular.ufo/glyphs/peh-ar.init.B_R_A_C_K_E_T_.varA_lt01.glif
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ com.schriftgestaltung.Glyphs._originalLayerName
+ 26 Jul 22, 15:58
+ com.schriftgestaltung.Glyphs.lastChange
+ 2022/07/27 08:01:34
+
+
+
diff --git a/tests/data/TestVarfea-Regular.ufo/glyphs/peh-ar.init.glif b/tests/data/TestVarfea-Regular.ufo/glyphs/peh-ar.init.glif
new file mode 100644
index 00000000..03c6ba3a
--- /dev/null
+++ b/tests/data/TestVarfea-Regular.ufo/glyphs/peh-ar.init.glif
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ com.schriftgestaltung.Glyphs.lastChange
+ 2022/07/27 08:01:34
+
+
+
diff --git a/tests/data/TestVarfea-Regular.ufo/glyphs/space.glif b/tests/data/TestVarfea-Regular.ufo/glyphs/space.glif
new file mode 100644
index 00000000..ad61b901
--- /dev/null
+++ b/tests/data/TestVarfea-Regular.ufo/glyphs/space.glif
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+ com.schriftgestaltung.Glyphs.lastChange
+ 2022/07/26 15:00:45
+
+
+
diff --git a/tests/data/TestVarfea-Regular.ufo/kerning.plist b/tests/data/TestVarfea-Regular.ufo/kerning.plist
new file mode 100644
index 00000000..696a1f40
--- /dev/null
+++ b/tests/data/TestVarfea-Regular.ufo/kerning.plist
@@ -0,0 +1,11 @@
+
+
+
+
+ alef-ar.fina
+
+ alef-ar.fina
+ 15
+
+
+
diff --git a/tests/data/TestVarfea-Regular.ufo/layercontents.plist b/tests/data/TestVarfea-Regular.ufo/layercontents.plist
new file mode 100644
index 00000000..b9c1a4f2
--- /dev/null
+++ b/tests/data/TestVarfea-Regular.ufo/layercontents.plist
@@ -0,0 +1,10 @@
+
+
+
+
+
+ public.default
+ glyphs
+
+
+
diff --git a/tests/data/TestVarfea-Regular.ufo/lib.plist b/tests/data/TestVarfea-Regular.ufo/lib.plist
new file mode 100644
index 00000000..0b5415f3
--- /dev/null
+++ b/tests/data/TestVarfea-Regular.ufo/lib.plist
@@ -0,0 +1,74 @@
+
+
+
+
+ com.github.googlei18n.ufo2ft.filters
+
+
+ name
+ eraseOpenCorners
+ namespace
+ glyphsLib.filters
+ pre
+
+
+
+ com.schriftgestaltung.appVersion
+ 3217
+ com.schriftgestaltung.customParameter.GSFont.DisplayStrings
+
+ اا
+ /dotabove-ar
+
+ com.schriftgestaltung.customParameter.GSFont.disablesAutomaticAlignment
+
+ com.schriftgestaltung.customParameter.GSFont.useNiceNames
+ 1
+ com.schriftgestaltung.customParameter.GSFontMaster.customValue
+ 0
+ com.schriftgestaltung.customParameter.GSFontMaster.customValue1
+ 0
+ com.schriftgestaltung.customParameter.GSFontMaster.customValue2
+ 0
+ com.schriftgestaltung.customParameter.GSFontMaster.customValue3
+ 0
+ com.schriftgestaltung.customParameter.GSFontMaster.iconName
+
+ com.schriftgestaltung.customParameter.GSFontMaster.weightValue
+ 100
+ com.schriftgestaltung.customParameter.GSFontMaster.widthValue
+ 100
+ com.schriftgestaltung.fontMasterID
+ m01
+ com.schriftgestaltung.fontMasterOrder
+ 0
+ com.schriftgestaltung.keyboardIncrement
+ 1
+ com.schriftgestaltung.weight
+ Regular
+ com.schriftgestaltung.weightValue
+ 100
+ com.schriftgestaltung.width
+ Regular
+ com.schriftgestaltung.widthValue
+ 100
+ public.glyphOrder
+
+ alef-ar.fina
+ peh-ar.init
+ space
+ dotabove-ar
+
+ public.postscriptNames
+
+ alef-ar.fina
+ uniFE8E
+ dotabove-ar
+ dotabovear
+ peh-ar.init
+ uniFB58
+ peh-ar.init.BRACKET.varAlt01
+ uniFB58.BRACKET.varAlt01
+
+
+
diff --git a/tests/data/TestVarfea-Regular.ufo/metainfo.plist b/tests/data/TestVarfea-Regular.ufo/metainfo.plist
new file mode 100644
index 00000000..7b8b34ac
--- /dev/null
+++ b/tests/data/TestVarfea-Regular.ufo/metainfo.plist
@@ -0,0 +1,10 @@
+
+
+
+
+ creator
+ com.github.fonttools.ufoLib
+ formatVersion
+ 3
+
+
diff --git a/tests/data/TestVarfea.designspace b/tests/data/TestVarfea.designspace
new file mode 100644
index 00000000..aa7a1a61
--- /dev/null
+++ b/tests/data/TestVarfea.designspace
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/data/TestVarfea.glyphs b/tests/data/TestVarfea.glyphs
new file mode 100644
index 00000000..5c0e945d
--- /dev/null
+++ b/tests/data/TestVarfea.glyphs
@@ -0,0 +1,646 @@
+{
+.appVersion = "3217";
+.formatVersion = 3;
+DisplayStrings = (
+"اا",
+"/dotabove-ar"
+);
+axes = (
+{
+name = Weight;
+tag = wght;
+}
+);
+date = "2022-07-26 14:49:29 +0000";
+familyName = TestVarfea;
+fontMaster = (
+{
+axesValues = (
+100
+);
+id = m01;
+metricValues = (
+{
+over = 16;
+pos = 600;
+},
+{
+over = -16;
+},
+{
+over = -16;
+pos = -400;
+},
+{
+},
+{
+}
+);
+name = Regular;
+},
+{
+axesValues = (
+1000
+);
+iconName = Bold;
+id = "B1C208F5-A14F-4863-B7E7-1D4BAD4C88B2";
+metricValues = (
+{
+pos = 600;
+},
+{
+},
+{
+pos = -400;
+},
+{
+pos = 700;
+},
+{
+pos = 500;
+}
+);
+name = Bold;
+userData = {
+GSCornerRadius = 15;
+GSOffsetHorizontal = -30;
+GSOffsetVertical = -25;
+};
+}
+);
+glyphs = (
+{
+glyphname = "alef-ar.fina";
+lastChange = "2023-11-20 16:51:14 +0000";
+layers = (
+{
+anchors = (
+{
+name = entry;
+pos = (299,97);
+},
+{
+name = top;
+pos = (211,730);
+}
+);
+layerId = m01;
+shapes = (
+{
+closed = 1;
+nodes = (
+(270,173,o),
+(283,133,o),
+(321,118,cs),
+(375,97,o),
+(403,105,o),
+(466,133,c),
+(491,13,l),
+(427,-19,o),
+(381,-25,o),
+(336,-25,cs),
+(139,-25,o),
+(160,121,o),
+(160,569,c),
+(270,601,l)
+);
+}
+);
+width = 600;
+},
+{
+anchors = (
+{
+name = entry;
+pos = (330,115);
+},
+{
+name = top;
+pos = (214,797);
+}
+);
+layerId = "B1C208F5-A14F-4863-B7E7-1D4BAD4C88B2";
+shapes = (
+{
+closed = 1;
+nodes = (
+(297,173,o),
+(310,133,o),
+(348,118,cs),
+(402,97,o),
+(433,86,o),
+(487,123,c),
+(565,-41,l),
+(501,-73,o),
+(400,-76,o),
+(355,-76,cs),
+(108,-76,o),
+(137,121,o),
+(137,569,c),
+(297,601,l)
+);
+}
+);
+width = 600;
+}
+);
+unicode = 1575;
+},
+{
+glyphname = "peh-ar.init";
+lastChange = "2022-07-27 08:01:34 +0000";
+layers = (
+{
+anchors = (
+{
+name = exit;
+pos = (161,54);
+}
+);
+layerId = m01;
+shapes = (
+{
+closed = 1;
+nodes = (
+(466,268,l),
+(450,61,o),
+(400,-33,o),
+(291,-56,cs),
+(165,-84,o),
+(107,-64,o),
+(67,22,c),
+(67,130,l),
+(124,89,o),
+(185,67,o),
+(241,67,cs),
+(332,67,o),
+(370,122,o),
+(378,268,c)
+);
+},
+{
+closed = 1;
+nodes = (
+(164,-235,ls),
+(158,-241,o),
+(158,-250,o),
+(164,-257,cs),
+(250,-347,ls),
+(256,-354,o),
+(265,-354,o),
+(272,-347,cs),
+(362,-261,ls),
+(368,-255,o),
+(368,-246,o),
+(362,-239,cs),
+(276,-149,ls),
+(270,-142,o),
+(261,-142,o),
+(254,-149,cs)
+);
+},
+{
+closed = 1;
+nodes = (
+(384,-235,ls),
+(378,-241,o),
+(378,-250,o),
+(384,-257,cs),
+(470,-347,ls),
+(476,-354,o),
+(485,-354,o),
+(492,-347,cs),
+(582,-261,ls),
+(588,-255,o),
+(588,-246,o),
+(582,-239,cs),
+(496,-149,ls),
+(490,-142,o),
+(481,-142,o),
+(474,-149,cs)
+);
+},
+{
+closed = 1;
+nodes = (
+(264,-435,ls),
+(258,-441,o),
+(258,-450,o),
+(264,-457,cs),
+(350,-547,ls),
+(356,-554,o),
+(365,-554,o),
+(372,-547,cs),
+(462,-461,ls),
+(468,-455,o),
+(468,-446,o),
+(462,-439,cs),
+(376,-349,ls),
+(370,-342,o),
+(361,-342,o),
+(354,-349,cs)
+);
+}
+);
+width = 600;
+},
+{
+anchors = (
+{
+name = exit;
+pos = (73,89);
+}
+);
+layerId = "B1C208F5-A14F-4863-B7E7-1D4BAD4C88B2";
+shapes = (
+{
+closed = 1;
+nodes = (
+(525,322,l),
+(509,19,o),
+(513,14,o),
+(342,-63,cs),
+(232,-113,o),
+(142,-102,o),
+(61,-87,c),
+(61,104,l),
+(104,84,o),
+(139,75,o),
+(167,75,cs),
+(252,75,o),
+(277,161,o),
+(291,322,c)
+);
+},
+{
+closed = 1;
+nodes = (
+(115,-208,ls),
+(107,-215,o),
+(107,-228,o),
+(115,-236,cs),
+(221,-347,ls),
+(229,-355,o),
+(241,-354,o),
+(248,-347,cs),
+(359,-241,ls),
+(367,-233,o),
+(367,-222,o),
+(359,-213,cs),
+(253,-102,ls),
+(246,-94,o),
+(233,-95,o),
+(226,-102,cs)
+);
+},
+{
+closed = 1;
+nodes = (
+(387,-208,ls),
+(379,-216,o),
+(379,-227,o),
+(387,-236,cs),
+(493,-347,ls),
+(500,-355,o),
+(513,-354,o),
+(520,-347,cs),
+(631,-241,ls),
+(639,-234,o),
+(639,-221,o),
+(631,-213,cs),
+(525,-102,ls),
+(517,-94,o),
+(505,-95,o),
+(498,-102,cs)
+);
+},
+{
+closed = 1;
+nodes = (
+(238,-455,ls),
+(230,-462,o),
+(230,-475,o),
+(238,-483,cs),
+(345,-594,ls),
+(352,-602,o),
+(364,-601,o),
+(372,-594,cs),
+(483,-488,ls),
+(491,-481,o),
+(491,-468,o),
+(483,-460,cs),
+(377,-349,ls),
+(369,-341,o),
+(357,-342,o),
+(350,-349,cs)
+);
+}
+);
+width = 600;
+},
+{
+anchors = (
+{
+name = exit;
+pos = (89,53);
+}
+);
+associatedMasterId = m01;
+attr = {
+axisRules = (
+{
+min = 600;
+}
+);
+};
+layerId = "8B3F4CCE-5E0D-437E-916C-4646A5030CF3";
+name = "26 Jul 22, 15:58";
+shapes = (
+{
+closed = 1;
+nodes = (
+(490,215,l),
+(469,-10,o),
+(412,-54,o),
+(266,-54,cs),
+(161,-54,o),
+(90,-27,o),
+(67,22,c),
+(67,130,l),
+(137,80,o),
+(173,67,o),
+(241,67,cs),
+(291,67,o),
+(315,118,o),
+(325,290,c)
+);
+},
+{
+closed = 1;
+nodes = (
+(194,-235,ls),
+(188,-241,o),
+(188,-250,o),
+(194,-257,cs),
+(280,-347,ls),
+(286,-354,o),
+(296,-353,o),
+(302,-347,cs),
+(369,-283,l),
+(430,-347,ls),
+(436,-354,o),
+(446,-353,o),
+(452,-347,cs),
+(542,-261,ls),
+(548,-255,o),
+(548,-246,o),
+(542,-239,cs),
+(456,-149,ls),
+(450,-142,o),
+(440,-143,o),
+(434,-149,cs),
+(367,-213,l),
+(306,-149,ls),
+(300,-142,o),
+(290,-143,o),
+(284,-149,cs)
+);
+},
+{
+closed = 1;
+nodes = (
+(264,-435,ls),
+(258,-441,o),
+(258,-450,o),
+(264,-457,cs),
+(350,-547,ls),
+(356,-554,o),
+(366,-553,o),
+(372,-547,cs),
+(462,-461,ls),
+(468,-455,o),
+(468,-446,o),
+(462,-439,cs),
+(376,-349,ls),
+(370,-342,o),
+(360,-343,o),
+(354,-349,cs)
+);
+}
+);
+width = 600;
+},
+{
+anchors = (
+{
+name = exit;
+pos = (73,85);
+}
+);
+associatedMasterId = "B1C208F5-A14F-4863-B7E7-1D4BAD4C88B2";
+attr = {
+axisRules = (
+{
+min = 600;
+}
+);
+};
+layerId = "3CC1DB06-AB7B-409C-841A-4209D835026A";
+name = "26 Jul 22, 15:59";
+shapes = (
+{
+closed = 1;
+nodes = (
+(525,322,l),
+(509,19,o),
+(513,14,o),
+(342,-63,cs),
+(232,-113,o),
+(142,-102,o),
+(61,-87,c),
+(61,104,l),
+(104,84,o),
+(139,75,o),
+(167,75,cs),
+(252,75,o),
+(277,161,o),
+(291,322,c)
+);
+},
+{
+closed = 1;
+nodes = (
+(153,-223,ls),
+(146,-230,o),
+(145,-242,o),
+(153,-250,cs),
+(259,-361,ls),
+(267,-370,o),
+(279,-369,o),
+(287,-361,cs),
+(369,-282,l),
+(445,-361,ls),
+(453,-369,o),
+(464,-369,o),
+(472,-361,cs),
+(583,-255,ls),
+(590,-248,o),
+(590,-236,o),
+(583,-228,cs),
+(477,-117,ls),
+(469,-108,o),
+(457,-109,o),
+(449,-117,cs),
+(367,-196,l),
+(291,-117,ls),
+(283,-109,o),
+(272,-109,o),
+(264,-117,cs)
+);
+},
+{
+closed = 1;
+nodes = (
+(208,-495,ls),
+(199,-504,o),
+(198,-519,o),
+(208,-529,cs),
+(339,-666,ls),
+(348,-676,o),
+(364,-675,o),
+(373,-666,cs),
+(510,-535,ls),
+(520,-526,o),
+(520,-511,o),
+(510,-501,cs),
+(379,-364,ls),
+(369,-354,o),
+(354,-355,o),
+(345,-364,cs)
+);
+}
+);
+width = 600;
+}
+);
+unicode = 1662;
+},
+{
+glyphname = space;
+lastChange = "2022-07-26 15:00:45 +0000";
+layers = (
+{
+layerId = m01;
+width = 200;
+},
+{
+layerId = "B1C208F5-A14F-4863-B7E7-1D4BAD4C88B2";
+width = 600;
+}
+);
+unicode = 32;
+},
+{
+glyphname = "dotabove-ar";
+lastChange = "2023-11-20 16:50:50 +0000";
+layers = (
+{
+anchors = (
+{
+name = _top;
+pos = (100,320);
+}
+);
+layerId = m01;
+shapes = (
+{
+closed = 1;
+nodes = (
+(104,232,l),
+(160,271,o),
+(187,303,o),
+(187,326,cs),
+(187,349,o),
+(170,372,o),
+(109,411,c),
+(100,411,l),
+(83,400,o),
+(30,341,o),
+(13,315,c),
+(13,306,l),
+(40,285,o),
+(68,260,o),
+(96,232,c)
+);
+}
+);
+width = 300;
+},
+{
+anchors = (
+{
+name = _top;
+pos = (125,416);
+}
+);
+layerId = "B1C208F5-A14F-4863-B7E7-1D4BAD4C88B2";
+shapes = (
+{
+closed = 1;
+nodes = (
+(129,292,l),
+(180,328,o),
+(231,372,o),
+(231,409,cs),
+(231,445,o),
+(196,472,o),
+(135,511,c),
+(124,511,l),
+(105,499,o),
+(38,425,o),
+(18,393,c),
+(18,382,l),
+(50,359,o),
+(60,350,o),
+(120,292,c)
+);
+}
+);
+width = 300;
+}
+);
+}
+);
+kerningLTR = {
+m01 = {
+"alef-ar.fina" = {
+"alef-ar.fina" = 15;
+};
+};
+"B1C208F5-A14F-4863-B7E7-1D4BAD4C88B2" = {
+"alef-ar.fina" = {
+"alef-ar.fina" = 35;
+};
+};
+};
+metrics = (
+{
+type = ascender;
+},
+{
+type = baseline;
+},
+{
+type = descender;
+},
+{
+type = "cap height";
+},
+{
+type = "x-height";
+}
+);
+unitsPerEm = 1000;
+versionMajor = 1;
+versionMinor = 0;
+}
diff --git a/tests/data/TestVariableFont-CFF2-cffsubr.ttx b/tests/data/TestVariableFont-CFF2-cffsubr.ttx
index a7ad531d..b2a8804a 100644
--- a/tests/data/TestVariableFont-CFF2-cffsubr.ttx
+++ b/tests/data/TestVariableFont-CFF2-cffsubr.ttx
@@ -328,7 +328,7 @@
-
+
diff --git a/tests/data/TestVariableFont-CFF2-post3.ttx b/tests/data/TestVariableFont-CFF2-post3.ttx
index 163c07fd..d8f17e02 100644
--- a/tests/data/TestVariableFont-CFF2-post3.ttx
+++ b/tests/data/TestVariableFont-CFF2-post3.ttx
@@ -305,7 +305,7 @@
-
+
diff --git a/tests/data/TestVariableFont-CFF2-useProductionNames.ttx b/tests/data/TestVariableFont-CFF2-useProductionNames.ttx
index b78c88cb..9bf82105 100644
--- a/tests/data/TestVariableFont-CFF2-useProductionNames.ttx
+++ b/tests/data/TestVariableFont-CFF2-useProductionNames.ttx
@@ -322,7 +322,7 @@
-
+
diff --git a/tests/data/TestVariableFont-CFF2.ttx b/tests/data/TestVariableFont-CFF2.ttx
index cfb50b2e..c53ebc96 100644
--- a/tests/data/TestVariableFont-CFF2.ttx
+++ b/tests/data/TestVariableFont-CFF2.ttx
@@ -319,7 +319,7 @@
-
+
diff --git a/tests/data/TestVariableFont-TTF-post3.ttx b/tests/data/TestVariableFont-TTF-post3.ttx
index 6b5fa50d..aa21f3ef 100644
--- a/tests/data/TestVariableFont-TTF-post3.ttx
+++ b/tests/data/TestVariableFont-TTF-post3.ttx
@@ -308,7 +308,7 @@
-
+
diff --git a/tests/data/TestVariableFont-TTF-useProductionNames.ttx b/tests/data/TestVariableFont-TTF-useProductionNames.ttx
index fff0a018..57466c5f 100644
--- a/tests/data/TestVariableFont-TTF-useProductionNames.ttx
+++ b/tests/data/TestVariableFont-TTF-useProductionNames.ttx
@@ -325,7 +325,7 @@
-
+
diff --git a/tests/data/TestVariableFont-TTF.ttx b/tests/data/TestVariableFont-TTF.ttx
index 63ff72b0..a7504b6c 100644
--- a/tests/data/TestVariableFont-TTF.ttx
+++ b/tests/data/TestVariableFont-TTF.ttx
@@ -322,7 +322,7 @@
-
+
diff --git a/tests/featureWriters/variableFeatureWriter_test.py b/tests/featureWriters/variableFeatureWriter_test.py
new file mode 100644
index 00000000..92092c65
--- /dev/null
+++ b/tests/featureWriters/variableFeatureWriter_test.py
@@ -0,0 +1,54 @@
+import io
+from textwrap import dedent
+
+from fontTools import designspaceLib
+
+from ufo2ft import compileVariableTTF
+
+
+def test_variable_features(FontClass):
+ tmp = io.StringIO()
+ designspace = designspaceLib.DesignSpaceDocument.fromfile(
+ "tests/data/TestVarfea.designspace"
+ )
+ designspace.loadSourceFonts(FontClass)
+ _ = compileVariableTTF(designspace, debugFeatureFile=tmp)
+
+ assert dedent("\n" + tmp.getvalue()) == dedent(
+ """
+ markClass dotabove-ar @MC_top;
+
+ lookup kern_Arab {
+ lookupflag IgnoreMarks;
+ pos alef-ar.fina alef-ar.fina <(wght=100:15 wght=1000:35) 0 (wght=100:15 wght=1000:35) 0>;
+ } kern_Arab;
+
+ feature kern {
+ script DFLT;
+ language dflt;
+ lookup kern_Arab;
+
+ script arab;
+ language dflt;
+ lookup kern_Arab;
+ } kern;
+
+ feature mark {
+ lookup mark2base {
+ pos base alef-ar.fina
+ mark @MC_top;
+ } mark2base;
+
+ } mark;
+
+ feature curs {
+ lookup curs {
+ lookupflag RightToLeft IgnoreMarks;
+ pos cursive alef-ar.fina ;
+ pos cursive peh-ar.init ;
+ pos cursive peh-ar.init.BRACKET.varAlt01 ;
+ } curs;
+
+ } curs;
+""" # noqa: B950
+ )
diff --git a/tests/integration_test.py b/tests/integration_test.py
index 05c2ba0d..0eb2a038 100644
--- a/tests/integration_test.py
+++ b/tests/integration_test.py
@@ -4,6 +4,7 @@
import re
import sys
from pathlib import Path
+from textwrap import dedent
import pytest
from fontTools.pens.boundsPen import BoundsPen
@@ -241,9 +242,23 @@ def test_debugFeatureFile(self, designspace):
tmp = io.StringIO()
_ = compileVariableTTF(designspace, debugFeatureFile=tmp)
-
- assert "### LayerFont-Regular ###" in tmp.getvalue()
- assert "### LayerFont-Bold ###" in tmp.getvalue()
+ assert "\n" + tmp.getvalue() == dedent(
+ """
+ markClass dotabovecomb @MC_top;
+
+ feature liga {
+ sub a e s s by s;
+ } liga;
+
+ feature mark {
+ lookup mark2base {
+ pos base e
+ mark @MC_top;
+ } mark2base;
+
+ } mark;
+ """ # noqa: B950
+ )
@pytest.mark.parametrize(
"output_format, options, expected_ttx",