Repository: googlefonts/ufo2ft
Pull request #635 (2022-07-19 12:44:13)

Compile variable features
This is a work in progress. The idea is this: when we are compiling a variable font from designspace, most of the time the feature files are exactly the same; the only thing which differs is the "implicit" rules created from anchors and kerning. Currently we build each font independently, and this means we compile binary features N times (where N=number of masters), and then merge them at the end. When there are overflows, compiling binary features N times is slow. Instead, this PR holds off the compilation of GSUB/GPOS tables to the end, and uses "variable" feature writers which describe how the kerning and anchors vary across the designspace. i.e. instead of:

```fea
### MutatorMathTest-LightCondensed ###
lookup kern_ltr {
    lookupflag IgnoreMarks;
    pos A J -10;
} kern_ltr;

### MutatorMathTest-BoldCondensed ###
lookup kern_ltr {
    lookupflag IgnoreMarks;
    pos A J -20;
} kern_ltr;
```

and compiles the features twice, it produces

```fea
lookup kern_ltr {
    lookupflag IgnoreMarks;
    pos A J (wght=0:-10 wght=1000:-20);
} kern_ltr;
```

and compiles the features once.

It's a lot faster.

- [x] Curs feature writer is untested.
- [x] There's some problem with enumerated kerns.
- [x] I'm not sure I trust it yet.

madig: So to trigger this, a font project must make sure to have the same exact feature file text in every source, meaning that you have to use variable feature syntax if there are differences in some parts of the Designspace?
simoncozens: Yes, it doesn’t attempt to do a text/AST-based merge of the feature files. At least not at this stage - maybe that can come later. I’m fairly sure that this will cover a good number of font projects, and those that it doesn’t get cover will get the old style merge.
madig: In one of our fonts, this seems to drop some kerning but not others 🤔 weird, drilling some more.

Also, the Rules writer is not hooked up and justr dropping it into the default feature writer list doesn't seem to do anything.
madig: So, I get rules to work when I graft the GSUB `<FeatureVariations>` from a previous ufo2ft and adapt the lookup numbers. This means that the rules writer should do whatever varLib is doing I guess.
madig: Weird. For my test font, no GSUB variations are emitted without the rules writer.
madig: Now we do approximately what varLib is doing with rules, though compared to a reference, the numbers and lookups are off in some places. Maybe I use `overlayFeatureVariations` wrong. At least with inspection by eye, stuff seems to work as expected.

There still remains a difference in kern behavior I need to investigate.
madig: Also todo: The test should include disjoint kerning pairs for different scripts, to ensure we handle disjoint kerning in different masters correctly (i.e. Armenian only in one, Greek only in the other).
madig: > I disabled the rules writer because varLib also handles designspace rules during the merge and I was ending up with them being emitted twice.

Oh, I know what's going on. My test sources happen to trigger the compile-features-once-but-later code path, so varLib adds feature variations and then we overwrite them when running the feature writers. This also seems to affect kerning.
madig: Next, my test source has e.g. an `r` vs `d` class-based kern that gets lost when compiling features later for VFs. 🤔 
simoncozens: You're using non-public test sources, which makes it difficult for other people to repro... I'll see what I can find.
simoncozens: Note to self: `_featuresCompatible` will return false if there are any ligature caret positions, which glyphsLib writes into the feature file (with per-master-file variations). Need to think how to handle these.
madig: Woops, yeah. I'll be back at work next week and can see if I can make a reproducer. For now, maybe just compile the font with identical features with 2.28.0 and then with this branch and compare kerning.

I thought I turned ligature writing off? Should be in https://github.com/googlefonts/glyphsLib/releases/tag/v6.0.7
anthrotype: yeah, the GdefFeatureWriter in ufo2ft should handle both the GlyphClassDefs (using public.openTypeCategories in the ufo lib) and the LigatureCarets (from the glyph anchors named "caret_", "vcaret_")
madig: So, I tried to come up with a minimal reproducer, but so far, no luck. What did seem to work was to make a union of all kerning values over all sources, putting in missing nones as zero-kerns. The specific pair I was looking at was actually "missing" such a zero-kern in one corner of  the design space. Maybe that's what's missing?
madig: varLib allows "sparse kerning", i.e. if Light and bold have a value but Regular doesn't, the value at Regular is interpolated rather than set to zero. So, filling in zero for missing kern pairs seems like the wrong thing to do.
madig: In my testing with a proprietary font, it seems the kerning is there, but it is not used, as if the shaper was stopping earlier. Subsetting the font to contain just the two glyphs I want to look at makes the kern work as expected. Will try to reproduce with an OSS font.
simoncozens: Here is a very minimal reproducer:
[RobotoSerif-Mini.glyphs.gz](https://github.com/googlefonts/ufo2ft/files/9417330/RobotoSerif-Mini.glyphs.gz)


simoncozens: With your patch and https://github.com/fonttools/fonttools/pull/2776, I think this is good now.

It has a slight change of behaviour compared to the past in respect of "empty" kern pairs, but I think this is for the better. If an intermediate master previously did not define a kern value for a pair which had kerning in other masters, we would assume that value was zero. For example, if you had Regular=-100, Bold=-200 and no kern defined in the SemiBold, you would get a kerning value of zero. Presumably designers would have realised that they didn't mean that, and put an explicit kern in there.

But now we can do better than that: if there is no explicit kern for an intermediate master, it will be interpolated. This should make people's kerning processes a lot faster...
simoncozens: The remaining test failures are due to the new behaviour described above.
simoncozens: OK, so one more bug we found: when backfilling kerns for missing masters, we shouldn't just use zero. varLib.merge also looks at more *general* rules in other masters and backfills using their value. e.g. if you have

```
enum pos @kern.1.o w -200;
pos @kern1.o @kern2.w -30;
```

in the regular master and 

```
pos @kern1.o @kern2.w -20;
```

in the bold, you *don't* want to do this:

```
enum pos @kern.1.o w (wght=400:-200 wght=800:0); # No equivalent rule, use zero
pos @kern1.o @kern2.w (wght=400:-30 wght=800:-20);
```

because that ends up stealing the kern altogether. instead you want to do this:

```
enum pos @kern.1.o w (wght=100:-200 wght=400:-20); # A more general rule covers this pair, use it
pos @kern1.o @kern2.w (wght=400:-30 wght=800:-20);
```
madig: Talking to Jany, Harry and Cosimo, we need to figure out all cases of this and what should happen in each. 🤔 Maybe it is even not possible to keep the split-kerns-by-script code and make it work with the existing merge flow? We'd need to figure out what to do if one mastr does not have kerning for something, just like here, because otehrwise varLib complains about https://github.com/googlefonts/ufo2ft/issues/642.
simoncozens: I don’t think there’s a mystery about what needs to happen.

If there is no exact equivalent kern pair in a master, the code should look to see if there is a kern provided by another class based rule, and if so backfill using that. If not it should use zero. This is exactly what varLib does in https://github.com/fonttools/fonttools/blob/main/Lib/fontTools/varLib/merger.py#L347-L355
madig: I think I am beginning to understand. This WIP commit seems to make basic text mostly match ufo2ft 2.28.0-compiled Roboto Serif, expcet for the "ft" pair, which seems to have a positive kern in the 2.28.0 version.
madig: Actually, can we reuse `fontTools.ufoLib.kerning.lookupKerningValue`? I see some other kerning diffs in Roboto Serif like in "f," and "øj", though.
madig: With the latest commits, I can only see kerning diffs for the following two pairs on my testing page:

* øj
* øy
madig: One thing to do would be to think about sparse kerning ([SparseKern.zip](https://github.com/googlefonts/ufo2ft/files/9464095/SparseKern.zip)) -- The Regular does not have a kernig value for AB. varLib will ensure there is no kerning value for it and the value will be interpolated, the latest commits here will insert a zero, making the kerning interpolate to zero in Regular. Sparse kerning should be supported.

simoncozens: I see a 20 unit kern for øj  and øy with this PR. However, it looks like we have managed to make the build considerably slower. A large number of `Already defined position for pair...` messages too.
madig: I noticed that the code doesn't seem to take into consideration sparse layers (Designspace sources with a layerName). Maybe it should.
madig: Rebased on main, now to fix the tests... currently I don't know what's going on with them.
madig: Note to self: if the sources are kerned such that we have e.g. `pos @kern1.Cyrl.PUNCT_hyphen @kern2.Cyrl.CYR_De_UC_2ND (wght=100:-20 wght=900:20);`, is it reasonable to interpolate the value at `wght=400` to appease the variation model instead of inserting a zero? I believe so. Still, find out what varLib does.
madig: Looking at a flood of warnings about regrouped glyphs, but the code makes me think they're bogus... Still trying to figure them out.
madig: FWIW, this produces kerning different from the merge path. I think I have to work on kerning-validator to 1. support comparing kerning between two fonts and 2. replicate the behaviour of varlib in determining the kerning value of a pair in a design space.
madig: I'm beginning to suspect that there is something else going on during compilation. I have this big font here that has seemingly identical kerning when I compile the wght-only variant, but has "missing kerning" when I compile the bigger wght+wdth+ital variant. The kern I looked at had an entry in the debug feature file, but the wght+wdth+ital TTF showed no kerning for it. Need to find an open font I can use for repros.
madig: I think I figured out the problem, at least for Roboto Serif. Compiling it shows missing kerning for the text "g,", but only at some locations and not others.

I subset the Italic Designspace (the uprights don't compile) to have just `space`, `g` and `comma` (using https://github.com/googlefonts/gftools/blob/main/Lib/gftools/ufomerge.py to merge it into an empty font, i.e. subset it this way) and then compiled a VF. The debug feature file showed this (edited for brevity, `GRAD=0,opsz=20,wdth=100,wght=400` is the default location, the other one some extreme end):

```fea
lookup kern_Latn {
    lookupflag IgnoreMarks;
    enum pos @kern1.Latn.g.alt comma (
        # ...
        GRAD=0,opsz=20,wdth=100,wght=400:0
        # ...
        GRAD=100,opsz=8,wdth=150,wght=900:-30
    );
    pos @kern1.Latn.g.alt @kern2.Latn.period (
        # ...
        GRAD=0,opsz=20,wdth=100,wght=400:-20
        # ...
        GRAD=100,opsz=8,wdth=150,wght=900:-40
        # ...
    );
} kern_Latn;
```

In the sources, the default location has a kerning pair 
```
pos @kern1.Latn.g.alt @kern2.Latn.period -20
```
and the extreme end at `GRAD=100,opsz=8,wdth=150,wght=900` has the kerning 
```
enum pos @kern1.Latn.g.alt comma -30
pos @kern1.Latn.g.alt @kern2.Latn.period -40
```

Since the kerning exception is only present in a few sources, but not the default one, the current code inserts a zero. When shaping "g,", the shaper grabs the first pair that matches, sees a zero, and stops. If it looked at the default location source in isolation, it would not find the exception and go on to the class to class kerning, resulting in the expected -20.

So... what to do about it. I think we need to handle exceptions specially by backfilling not a zero or nothing if it does not exist in all locations but the value from "higher up". In this case, the class-to-glyph pair would carry all values of the class-to-class value but overwrite some with its own?

I think the lookupKerningValue from ufoLib would do that, but I used it before and it had the same problem, so maybe I held it wrong.

Maybe I could do an extra pass after having collected all kerning from all sources, to find kerning exceptions and backfill missing locations (i.e. all class-to-glyph and glyph-to-class kernings would inherit values from the class-to-class parent, then all glyph-to-glyph pairs would inherit from their class-to-glyph and glyph-to-class parents) 🤔 
madig: I believe this script does what needs to be done: https://gist.github.com/madig/76567a9650de639bbff51ce010783790, both for variable feature compilation and the old merge-to-VF flow, _and_ instance interpolation, unless I'm missing something @Hoolean. If so, I'd need to work this into kerning-validator as a model.
Hoolean: > I believe this script does what needs to be done: https://gist.github.com/madig/76567a9650de639bbff51ce010783790, both for variable feature compilation and the old merge-to-VF flow, _and_ instance interpolation, unless I'm missing something @Hoolean. If so, I'd need to work this into kerning-validator as a model.

The script's union kerning step largely maintains fontmake's variation model but we need to cover an additional case:

- ✅ For **sources with kerning and/or GPOS features**, the current behaviour is correct
- :x: For **other sources**, the current behaviour will insert 0-value kerns at every pair, where the values should be interpolated at this source's position in the final font instead

If our goal is to make every kerning value explicit at the source-level, the latter case may be difficult to handle without re-implementing feature interpolation.
Hoolean: My above comment can be largely discounted after we revisited googlefonts/fontmake#998. Having discussed with @anthrotype, **we only need to maintain interpolated kerning for sparse layers**, and can drop the edge case of interpolated kerning for UFO sources that have an empty or absent kerning.plist and GPOS features:

* The behaviour likely originates from a bug with how we invoke varLib;
* The scenario is highly-specific and awkward to achieve (most sources have at least some GPOS features, e.g. even if implicitly from anchors);
* Automatic testing of a corpus of fonts found no sources using this behaviour; and
* Interpolation can be easily replicated by inserting explicit values, which is used in all other scenarios.

Sparse layers only contribute glyph outlines, thus neither contribute kerning nor participate in kerning interpolation; any kerning at their location will be interpolated at runtime from other kerning-contributing sources. All other sources have a kerning dictionary associated with them, and should participate in the kerning interpolation even if their kerning.plist is absent or empty; they should implicitly contribute zeros for the union of kerning pairs across sources.

This simplifies what we expect for kerning in the final font to:

* Sparse layers: fully interpolated
* All other layers: no interpolation
m4rc1e: I've just hit the dreaded https://github.com/googlefonts/fontmake/issues/894 issue with Merriweather VF so I don't mind getting through @Hoolean's outstanding tasks. This solution does seem better than the previous attempts so I don't mind investing some time into this.
Hoolean: @m4rc1e Hi Marc, happy to chat in more depth if this would be helpful for that :)

This PR – when completed – should indeed be able to avoid that fontMake issue, although it would depend on the feature files being identical across Merriweather's sources, probably relying on using variable syntax in some places :+1:
simoncozens: I actually need this functionality for a project now, so I've rebased this PR. However, I still see a few problems:

* Mark anchors positioning (`_top` etc.) is not varied. (I'll fix this.)
* Something is wrong with cursive positioning. (I'll also try to fix this.)
* Integration tests are failing and giving significantly fewer variation regions for me locally, although I notice they're succeeding on the CI?!
anthrotype: maybe you and CI are using different fonttoolses?
anthrotype: CI is pinned at https://github.com/googlefonts/ufo2ft/blob/efb4658890ba645292a9c6bc055c91afe225c4a1/requirements.txt#L1
simoncozens: OK, those issues are fixed, but I found a new one: we've lost the public.openTypeCategories information and now marks are missing from my GDEF table.
anthrotype: > we've lost the public.openTypeCategories information

is that stored at the single-master level? we should probably try fetch it from the base master or preferably from the DS.lib because that should really be a global kind of data that applies to all masters
simoncozens: Yeah, we can try both. OK, this is working for my purposes now. I'm not sure what the state of the kern debate is.
simoncozens: Something weird happening with (ligature) mark positioning, although the numbers look right for me in the variable feature file...
madig: > I'm not sure what the state of the kern debate is.

What debate?
simoncozens: The ligatures problem is a fontTools ones, PR coming soon. 
anthrotype: > The ligatures problem is a fontTools ones, PR coming soon.

@simoncozens https://github.com/fonttools/fonttools/releases/tag/4.44.1
madig: > but I am not sure what's the status of the "next steps" that he mentioned in https://github.com/googlefonts/ufo2ft/pull/635#issuecomment-1589588070

Probably untouched.
Hoolean: > but I am not sure what's the status of the "next steps" that he mentioned in https://github.com/googlefonts/ufo2ft/pull/635#issuecomment-1589588070

Hello all! Yes, can confirm that the state of the Next Steps above is accurate; we made the main changes to the kerning but it still needs more thorough testing.

We began to adapt [kerning-validator](https://github.com/daltonmaag/kerning-validator) to test VFs but I have not had availability to complete this since, and so if anyone wishes to resume this ufo2ft PR then there would be no duplicated effort (and it would be much appreciated too :rocket:).
simoncozens: This works beautifully on variable builds but seems to have broken static builds.
anthrotype: broken how? Aren't these only used for variable builds?
simoncozens: I'm still trying to track it down, but I'm seeing

```
  File "/Users/simon/others-repos/ufo2ft/Lib/ufo2ft/featureWriters/baseFeatureWriter.py", line 139, in write
    self.setContext(font, feaFile, compiler=compiler)
  File "/Users/simon/others-repos/ufo2ft/Lib/ufo2ft/featureWriters/variableMarkWriter.py", line 20, in setContext
    font=self.context.font.findDefault().font,
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AttributeError: 'Font' object has no attribute 'findDefault'
```

during a static build.
simoncozens: Oh, I bet it's my glyphsLib thing, actually (https://github.com/googlefonts/glyphsLib/pull/957). GlyphsLib's mark feature writer needs to be variable or not dependent on the build.
anthrotype: > I should re-organise this such that the feature writers do the right thing based on whether they're handed a font or a designspace

yeah, that may be preferable indeed
anthrotype: have you rebased this on main? I think I have recently bumped fonttools and updated the integration tests in https://github.com/googlefonts/ufo2ft/pull/795

newer fonttools correctly prunes unused VarRegions (like in the above ttx dump) when optimizing VarStore 
anthrotype: or if you can't rebase, just git fetch origin && git merge origin/main
simoncozens: Yes, this was rebased yesterday and includes f51c9f7f. The problem was that the branch also included another commit which also updated the expectations! So I've dropped that and now I have this change:

```
           <VarRegionAxis index="0">
-            <StartCoord value="0.0"/>
+            <StartCoord value="0.36365"/>
             <PeakCoord value="1.0"/>
             <EndCoord value="1.0"/>
           </VarRegionAxis>
```

This is with a designspace which has an intermediate layer at wght=450, and a mark positioning rule like so:

```
                    pos base e
                        <anchor (wght=350:314 wght=450:314 wght=625:315) (wght=350:556 wght=450:556 wght=625:644)> mark @MC_top;
```

I'm going to argue that this change (starting the variation at 450, not 350) is actually correct and that the current behaviour of ufo2ft is wrong: it doesn't respect variations with respect to marks in intermediate layers.
anthrotype: > the current behaviour of ufo2ft is wrong: it doesn't respect variations with respect to marks in intermediate layers.

that's right, I think this is => https://github.com/googlefonts/ufo2ft/issues/640
simoncozens: It's fairly gross that we have an integration test for behaviour that we know to be wrong. ;-) Anyway, fixed now.
madig: Has the kerning getter been updated to do what https://gist.github.com/madig/76567a9650de639bbff51ce010783790#file-align-groups-py-L122-L154 is doing? I can't tell.
anthrotype: > Has the kerning getter been updated to do what https://gist.github.com/madig/76567a9650de639bbff51ce010783790#file-align-groups-py-L122-L154 is doing? I can't tell.

@madig I think so, I can see the `lookupKerningValue` being used in the kernFeatureWriter, I think @Hoolean added that in https://github.com/googlefonts/ufo2ft/commit/effd4baeb1c5a1897079b357e82b291455c8ca4f
simoncozens: Stand by for all kinds of git madness to get this vaguely rebaseable.
simoncozens: Right, I'm done here. Remember that without https://github.com/googlefonts/glyphsLib/pull/957 you will get wrong answers compiling Glyphs files.
anthrotype: Thanks Simon, I really appreciate this effort.
So, I tried to run this on ramdom font, Oswald, and compared the ttx outputs for the fonts built with ufo2ft main vs compile-variable-features branch and I see a lot of diffs in GDEF/GPOS.
I am a bit hesitant to enable this by default and was wondering if we could add this feature behind an (opt-in) option?
We could also detect if any of the hand-written FEA contains any variable FEA syntax and if so compile them using the new approach, otherwise keep using the old one unless the user requests otherwise?
anthrotype: regarding some of the diffs, I notice for example that the GDEF ligature carets are no longer variable (Format=1 instead of Format=3 CaretValue with accompanying delta set indices) for the font compiled using `compile-variable-features` branch.
Is this a known limitation?
anthrotype: note to self, the new https://github.com/googlefonts/fontc/tree/main/layout-normalizer (used by fontc to compare vs fontmake) could also be used to compare the GPOS tables from varlib-merged font vs variable FEA font
simoncozens: > regarding some of the diffs, I notice for example that the GDEF ligature carets are no longer variable (Format=1 instead of Format=3 CaretValue with accompanying delta set indices) for the font compiled using `compile-variable-features` branch. Is this a known limitation?

This wasn't known (at least not to me!) and, yes, it should be fixed.

> note to self, the new https://github.com/googlefonts/fontc/tree/main/layout-normalizer (used by fontc to ttx_diff vs fontmake) could also be used to compare the GPOS tables from varlib-merged font vs variable FEA font

I'd appreciate looking at these normalised diffs.
anthrotype: you can run the fontc' layout-normalizer like this, from the fontc repository's root:

```
$ cargo run -p layout-normalizer -t GPOS -o font1.txt font1.ttf
$ cargo run -p layout-normalizer -t GPOS -o font2.txt font2.ttf
```

then you can diff the two text files

I can already say that for Oswald at least, there are _no_ differences in the kern feature, which I think counts as a great accomplishment! Well done us.

The diffs are only in the mark/mkmk and don't look too big. You can take a look at the files below, or just regenerate as above:

[Oswald-layout-normalizer-output.zip](https://github.com/googlefonts/ufo2ft/files/13582478/Oswald-layout-normalizer-output.zip)

anthrotype: > Those diffs are just mark quantisation

did that change with this PR?
simoncozens: OK, it's not quantisation but rounding. In the sources of Oswald's bold master, the brevebelowcomb has a bottom anchor at Y=-194.525. Current ufo2ft otRounds that to -195; this PR does not otRound a variable scalar, so it ends up as being -194. I think the fix is probably to otRound the number when it's being added to the variable scalar.
anthrotype: Ok so @cmyr fixed a few bugs in the layout-normalizer so I have tried to run it again on the Oswalds generated with fontmake-main vs with this PR applied, and I now see there are a lot of missing kerning pairs in the font built with ufo2ft/compile-variable-features.
I attache the output from layout-normalized below. (I have used this branch https://github.com/googlefonts/fontc/pull/654 as it includes all bugfixes plus adds support for extension lookups, not merged to main yet).

@simoncozens can you please take a look?

[Oswald-layout-normalizer-output-2.zip](https://github.com/googlefonts/ufo2ft/files/13673282/Oswald-layout-normalizer-output-2.zip)

anthrotype: actually, it's also possible that the layout-normalizer has some other bugs, because IIRC it was previously reporting the kerning to be the same for Oswald at least, and this PR didn't change in the meantime (the normalizer did). Sorry for the noise..
simoncozens: Yep, I found the same, and then when I actually *tried* the kern pairs at the reported locations I got identical results between the two files. So it's worth checking manually.
anthrotype: Yes, I can also confirm those diffs reported by layout-normalizer between `main` vs `vfea` (i.e. ufo2ft/compile-variable-features branch) are spurious, because hb-shape says that they do in fact match. Below an excerpt

```diff
--- Oswald-VF-main.markkern.txt 2023-12-14 12:34:44
+++ Oswald-VF-vfea.markkern.txt 2023-12-14 12:34:35
@@ -1,119 +1,33 @@
 # GPOS #

-# kern: DFLT/dflt ## 1824 PairPos rules
+# kern: DFLT/dflt ## 1572 PairPos rules
 # lookupflag LookupFlag(8)
-A -1 {-2,0} Ccedilla
 A  Q
-A -39 {-42,-34} V
-A -7 {-10,-2} v
-A -57 {-60,-52} [quotedblright,quoteright]
-Aacute -1 {-2,0} Ccedilla
 Aacute  Q
-Aacute -39 {-42,-34} V
-Aacute -7 {-10,-2} v
-Aacute -57 {-60,-52} [quotedblright,quoteright]
 Abreve  Q
-Abreve -39 {-42,-34} V
-Abreve -7 {-10,-2} v
-Abreve -57 {-60,-52} [quotedblright,quoteright]
 uni1EAE  Q
-uni1EAE -39 {-42,-34} V
-uni1EAE -7 {-10,-2} v
-uni1EAE -57 {-60,-52} [quotedblright,quoteright]
```
simoncozens: Ship it! :-)

Patch:
diff --git a/Lib/ufo2ft/__init__.py b/Lib/ufo2ft/__init__.py
index 89f4ba4..5f9e61d 100644
--- a/Lib/ufo2ft/__init__.py
+++ b/Lib/ufo2ft/__init__.py
@@ -18,6 +18,11 @@ __all__ = [
     "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 b2704f8..4175cd0 100644
--- a/Lib/ufo2ft/_compilers/baseCompiler.py
+++ b/Lib/ufo2ft/_compilers/baseCompiler.py
@@ -4,13 +4,19 @@ from collections import defaultdict
 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 @@ class BaseCompiler:
         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 @@ class BaseInterpolatableCompiler(BaseCompiler):
             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 @@ class BaseInterpolatableCompiler(BaseCompiler):
             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 @@ class BaseInterpolatableCompiler(BaseCompiler):
             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 @@ class BaseInterpolatableCompiler(BaseCompiler):
 
         (
             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 c9c668e..9ebdde9 100644
--- a/Lib/ufo2ft/_compilers/interpolatableOTFCompiler.py
+++ b/Lib/ufo2ft/_compilers/interpolatableOTFCompiler.py
@@ -7,6 +7,7 @@ from fontTools import varLib
 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 @@ class InterpolatableOTFCompiler(OTFCompiler, BaseInterpolatableCompiler):
             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 a15e2e0..a5d8b0f 100644
--- a/Lib/ufo2ft/_compilers/interpolatableTTFCompiler.py
+++ b/Lib/ufo2ft/_compilers/interpolatableTTFCompiler.py
@@ -31,9 +31,9 @@ class InterpolatableTTFCompiler(BaseInterpolatableCompiler):
         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 @@ class InterpolatableTTFCompiler(BaseInterpolatableCompiler):
         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 0e3acfb..6c76bce 100644
--- a/Lib/ufo2ft/_compilers/otfCompiler.py
+++ b/Lib/ufo2ft/_compilers/otfCompiler.py
@@ -33,4 +33,5 @@ class OTFCompiler(BaseCompiler):
             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 a5c32f4..05bd265 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 83c8044..b79cb54 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 50ca7ac..e9e75b7 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 788e21d..e1c6bc9 100644
--- a/Lib/ufo2ft/featureCompiler.py
+++ b/Lib/ufo2ft/featureCompiler.py
@@ -9,6 +9,7 @@ from io import StringIO
 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 @@ class BaseFeatureCompiler:
 
         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 b69f834..32a15e4 100644
--- a/Lib/ufo2ft/featureWriters/baseFeatureWriter.py
+++ b/Lib/ufo2ft/featureWriters/baseFeatureWriter.py
@@ -2,10 +2,19 @@ import logging
 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 @@ class BaseFeatureWriter:
             todo=todo,
             insertComments=insertComments,
             existingFeatures=existing,
+            isVariable=isinstance(font, DesignSpaceDocument),
         )
 
         return self.context
@@ -312,6 +322,8 @@ class BaseFeatureWriter:
         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 @@ class BaseFeatureWriter:
                 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 @@ class BaseFeatureWriter:
             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 @@ class BaseFeatureWriter:
         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 @@ class BaseFeatureWriter:
         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 020b4d8..70f4ae4 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 @@ class CursFeatureWriter(BaseFeatureWriter):
 
         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 1452759..fcd41f1 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 @@ class GdefFeatureWriter(BaseFeatureWriter):
                     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 0ce7c7e..0713bb8 100644
--- a/Lib/ufo2ft/featureWriters/kernFeatureWriter.py
+++ b/Lib/ufo2ft/featureWriters/kernFeatureWriter.py
@@ -4,9 +4,13 @@ import itertools
 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.featureWriters import BaseFeatureWriter, ast
 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 @@ class KernFeatureWriter(BaseFeatureWriter):
         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 @@ class KernFeatureWriter(BaseFeatureWriter):
             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 @@ class KernFeatureWriter(BaseFeatureWriter):
 
         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 @@ class KernFeatureWriter(BaseFeatureWriter):
             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 @@ class KernFeatureWriter(BaseFeatureWriter):
 
             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 d8e620a..22fbc16 100644
--- a/Lib/ufo2ft/featureWriters/markFeatureWriter.py
+++ b/Lib/ufo2ft/featureWriters/markFeatureWriter.py
@@ -3,13 +3,11 @@ import re
 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 @@ class AbstractMarkPos:
 
     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 @@ class MarkToLigaPos(AbstractMarkPos):
     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 @@ class MarkFeatureWriter(BaseFeatureWriter):
                     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 @@ class MarkFeatureWriter(BaseFeatureWriter):
         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 48d56a7..d034f0b 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 fa193fd..10f9761 100644
--- a/setup.py
+++ b/setup.py
@@ -29,7 +29,7 @@ setup(
     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",
     ],


Test patch:
diff --git a/tests/data/TestVarfea-Bold.ufo/fontinfo.plist b/tests/data/TestVarfea-Bold.ufo/fontinfo.plist
new file mode 100644
index 0000000..d6b5d79
--- /dev/null
+++ b/tests/data/TestVarfea-Bold.ufo/fontinfo.plist
@@ -0,0 +1,36 @@
+<?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>600</integer>
+    <key>capHeight</key>
+    <integer>700</integer>
+    <key>descender</key>
+    <integer>-400</integer>
+    <key>familyName</key>
+    <string>TestVarfea</string>
+    <key>italicAngle</key>
+    <integer>0</integer>
+    <key>openTypeHeadCreated</key>
+    <string>2022/07/26 14:49:29</string>
+    <key>openTypeOS2Type</key>
+    <array>
+      <integer>3</integer>
+    </array>
+    <key>postscriptUnderlinePosition</key>
+    <integer>-100</integer>
+    <key>postscriptUnderlineThickness</key>
+    <integer>50</integer>
+    <key>styleName</key>
+    <string>Bold</string>
+    <key>unitsPerEm</key>
+    <integer>1000</integer>
+    <key>versionMajor</key>
+    <integer>1</integer>
+    <key>versionMinor</key>
+    <integer>0</integer>
+    <key>xHeight</key>
+    <integer>500</integer>
+  </dict>
+</plist>
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 0000000..873945f
--- /dev/null
+++ b/tests/data/TestVarfea-Bold.ufo/glyphs/alef-ar.fina.glif
@@ -0,0 +1,31 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<glyph name="alef-ar.fina" format="2">
+  <advance width="600"/>
+  <unicode hex="0627"/>
+  <anchor x="330" y="115" name="entry"/>
+  <anchor x="214" y="797" name="top"/>
+  <outline>
+    <contour>
+      <point x="297" y="601" type="line"/>
+      <point x="297" y="173"/>
+      <point x="310" y="133"/>
+      <point x="348" y="118" type="curve" smooth="yes"/>
+      <point x="402" y="97"/>
+      <point x="433" y="86"/>
+      <point x="487" y="123" type="curve"/>
+      <point x="565" y="-41" type="line"/>
+      <point x="501" y="-73"/>
+      <point x="400" y="-76"/>
+      <point x="355" y="-76" type="curve" smooth="yes"/>
+      <point x="108" y="-76"/>
+      <point x="137" y="121"/>
+      <point x="137" y="569" type="curve"/>
+    </contour>
+  </outline>
+  <lib>
+    <dict>
+      <key>com.schriftgestaltung.Glyphs.lastChange</key>
+      <string>2023/11/20 16:51:14</string>
+    </dict>
+  </lib>
+</glyph>
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 0000000..4572eab
--- /dev/null
+++ b/tests/data/TestVarfea-Bold.ufo/glyphs/contents.plist
@@ -0,0 +1,16 @@
+<?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>alef-ar.fina</key>
+    <string>alef-ar.fina.glif</string>
+    <key>dotabove-ar</key>
+    <string>dotabove-ar.glif</string>
+    <key>peh-ar.init</key>
+    <string>peh-ar.init.glif</string>
+    <key>peh-ar.init.BRACKET.varAlt01</key>
+    <string>peh-ar.init.B_R_A_C_K_E_T_.varA_lt01.glif</string>
+    <key>space</key>
+    <string>space.glif</string>
+  </dict>
+</plist>
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 0000000..05dde66
--- /dev/null
+++ b/tests/data/TestVarfea-Bold.ufo/glyphs/dotabove-ar.glif
@@ -0,0 +1,31 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<glyph name="dotabove-ar" format="2">
+  <anchor x="125" y="416" name="_top"/>
+  <outline>
+    <contour>
+      <point x="120" y="292" type="curve"/>
+      <point x="129" y="292" type="line"/>
+      <point x="180" y="328"/>
+      <point x="231" y="372"/>
+      <point x="231" y="409" type="curve" smooth="yes"/>
+      <point x="231" y="445"/>
+      <point x="196" y="472"/>
+      <point x="135" y="511" type="curve"/>
+      <point x="124" y="511" type="line"/>
+      <point x="105" y="499"/>
+      <point x="38" y="425"/>
+      <point x="18" y="393" type="curve"/>
+      <point x="18" y="382" type="line"/>
+      <point x="50" y="359"/>
+      <point x="60" y="350"/>
+    </contour>
+  </outline>
+  <lib>
+    <dict>
+      <key>com.schriftgestaltung.Glyphs.lastChange</key>
+      <string>2023/11/20 16:50:50</string>
+      <key>com.schriftgestaltung.Glyphs.originalWidth</key>
+      <integer>300</integer>
+    </dict>
+  </lib>
+</glyph>
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 0000000..82fbc0d
--- /dev/null
+++ b/tests/data/TestVarfea-Bold.ufo/glyphs/layerinfo.plist
@@ -0,0 +1,19 @@
+<?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>lib</key>
+    <dict>
+      <key>com.schriftgestaltung.layerId</key>
+      <string>B1C208F5-A14F-4863-B7E7-1D4BAD4C88B2</string>
+      <key>com.schriftgestaltung.layerOrderInGlyph.alef-ar.fina</key>
+      <integer>1</integer>
+      <key>com.schriftgestaltung.layerOrderInGlyph.dotabove-ar</key>
+      <integer>1</integer>
+      <key>com.schriftgestaltung.layerOrderInGlyph.peh-ar.init</key>
+      <integer>1</integer>
+      <key>com.schriftgestaltung.layerOrderInGlyph.space</key>
+      <integer>1</integer>
+    </dict>
+  </dict>
+</plist>
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 0000000..82cc961
--- /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 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<glyph name="peh-ar.init.BRACKET.varAlt01" format="2">
+  <advance width="600"/>
+  <anchor x="73" y="85" name="exit"/>
+  <outline>
+    <contour>
+      <point x="291" y="322" type="curve"/>
+      <point x="525" y="322" type="line"/>
+      <point x="509" y="19"/>
+      <point x="513" y="14"/>
+      <point x="342" y="-63" type="curve" smooth="yes"/>
+      <point x="232" y="-113"/>
+      <point x="142" y="-102"/>
+      <point x="61" y="-87" type="curve"/>
+      <point x="61" y="104" type="line"/>
+      <point x="104" y="84"/>
+      <point x="139" y="75"/>
+      <point x="167" y="75" type="curve" smooth="yes"/>
+      <point x="252" y="75"/>
+      <point x="277" y="161"/>
+    </contour>
+    <contour>
+      <point x="264" y="-117" type="curve" smooth="yes"/>
+      <point x="153" y="-223" type="line" smooth="yes"/>
+      <point x="146" y="-230"/>
+      <point x="145" y="-242"/>
+      <point x="153" y="-250" type="curve" smooth="yes"/>
+      <point x="259" y="-361" type="line" smooth="yes"/>
+      <point x="267" y="-370"/>
+      <point x="279" y="-369"/>
+      <point x="287" y="-361" type="curve" smooth="yes"/>
+      <point x="369" y="-282" type="line"/>
+      <point x="445" y="-361" type="line" smooth="yes"/>
+      <point x="453" y="-369"/>
+      <point x="464" y="-369"/>
+      <point x="472" y="-361" type="curve" smooth="yes"/>
+      <point x="583" y="-255" type="line" smooth="yes"/>
+      <point x="590" y="-248"/>
+      <point x="590" y="-236"/>
+      <point x="583" y="-228" type="curve" smooth="yes"/>
+      <point x="477" y="-117" type="line" smooth="yes"/>
+      <point x="469" y="-108"/>
+      <point x="457" y="-109"/>
+      <point x="449" y="-117" type="curve" smooth="yes"/>
+      <point x="367" y="-196" type="line"/>
+      <point x="291" y="-117" type="line" smooth="yes"/>
+      <point x="283" y="-109"/>
+      <point x="272" y="-109"/>
+    </contour>
+    <contour>
+      <point x="345" y="-364" type="curve" smooth="yes"/>
+      <point x="208" y="-495" type="line" smooth="yes"/>
+      <point x="199" y="-504"/>
+      <point x="198" y="-519"/>
+      <point x="208" y="-529" type="curve" smooth="yes"/>
+      <point x="339" y="-666" type="line" smooth="yes"/>
+      <point x="348" y="-676"/>
+      <point x="364" y="-675"/>
+      <point x="373" y="-666" type="curve" smooth="yes"/>
+      <point x="510" y="-535" type="line" smooth="yes"/>
+      <point x="520" y="-526"/>
+      <point x="520" y="-511"/>
+      <point x="510" y="-501" type="curve" smooth="yes"/>
+      <point x="379" y="-364" type="line" smooth="yes"/>
+      <point x="369" y="-354"/>
+      <point x="354" y="-355"/>
+    </contour>
+  </outline>
+  <lib>
+    <dict>
+      <key>com.schriftgestaltung.Glyphs._originalLayerName</key>
+      <string>26 Jul 22, 15:59</string>
+      <key>com.schriftgestaltung.Glyphs.lastChange</key>
+      <string>2022/07/27 08:01:34</string>
+    </dict>
+  </lib>
+</glyph>
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 0000000..a8e8750
--- /dev/null
+++ b/tests/data/TestVarfea-Bold.ufo/glyphs/peh-ar.init.glif
@@ -0,0 +1,84 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<glyph name="peh-ar.init" format="2">
+  <advance width="600"/>
+  <unicode hex="067E"/>
+  <anchor x="73" y="89" name="exit"/>
+  <outline>
+    <contour>
+      <point x="291" y="322" type="curve"/>
+      <point x="525" y="322" type="line"/>
+      <point x="509" y="19"/>
+      <point x="513" y="14"/>
+      <point x="342" y="-63" type="curve" smooth="yes"/>
+      <point x="232" y="-113"/>
+      <point x="142" y="-102"/>
+      <point x="61" y="-87" type="curve"/>
+      <point x="61" y="104" type="line"/>
+      <point x="104" y="84"/>
+      <point x="139" y="75"/>
+      <point x="167" y="75" type="curve" smooth="yes"/>
+      <point x="252" y="75"/>
+      <point x="277" y="161"/>
+    </contour>
+    <contour>
+      <point x="226" y="-102" type="curve" smooth="yes"/>
+      <point x="115" y="-208" type="line" smooth="yes"/>
+      <point x="107" y="-215"/>
+      <point x="107" y="-228"/>
+      <point x="115" y="-236" type="curve" smooth="yes"/>
+      <point x="221" y="-347" type="line" smooth="yes"/>
+      <point x="229" y="-355"/>
+      <point x="241" y="-354"/>
+      <point x="248" y="-347" type="curve" smooth="yes"/>
+      <point x="359" y="-241" type="line" smooth="yes"/>
+      <point x="367" y="-233"/>
+      <point x="367" y="-222"/>
+      <point x="359" y="-213" type="curve" smooth="yes"/>
+      <point x="253" y="-102" type="line" smooth="yes"/>
+      <point x="246" y="-94"/>
+      <point x="233" y="-95"/>
+    </contour>
+    <contour>
+      <point x="498" y="-102" type="curve" smooth="yes"/>
+      <point x="387" y="-208" type="line" smooth="yes"/>
+      <point x="379" y="-216"/>
+      <point x="379" y="-227"/>
+      <point x="387" y="-236" type="curve" smooth="yes"/>
+      <point x="493" y="-347" type="line" smooth="yes"/>
+      <point x="500" y="-355"/>
+      <point x="513" y="-354"/>
+      <point x="520" y="-347" type="curve" smooth="yes"/>
+      <point x="631" y="-241" type="line" smooth="yes"/>
+      <point x="639" y="-234"/>
+      <point x="639" y="-221"/>
+      <point x="631" y="-213" type="curve" smooth="yes"/>
+      <point x="525" y="-102" type="line" smooth="yes"/>
+      <point x="517" y="-94"/>
+      <point x="505" y="-95"/>
+    </contour>
+    <contour>
+      <point x="350" y="-349" type="curve" smooth="yes"/>
+      <point x="238" y="-455" type="line" smooth="yes"/>
+      <point x="230" y="-462"/>
+      <point x="230" y="-475"/>
+      <point x="238" y="-483" type="curve" smooth="yes"/>
+      <point x="345" y="-594" type="line" smooth="yes"/>
+      <point x="352" y="-602"/>
+      <point x="364" y="-601"/>
+      <point x="372" y="-594" type="curve" smooth="yes"/>
+      <point x="483" y="-488" type="line" smooth="yes"/>
+      <point x="491" y="-481"/>
+      <point x="491" y="-468"/>
+      <point x="483" y="-460" type="curve" smooth="yes"/>
+      <point x="377" y="-349" type="line" smooth="yes"/>
+      <point x="369" y="-341"/>
+      <point x="357" y="-342"/>
+    </contour>
+  </outline>
+  <lib>
+    <dict>
+      <key>com.schriftgestaltung.Glyphs.lastChange</key>
+      <string>2022/07/27 08:01:34</string>
+    </dict>
+  </lib>
+</glyph>
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 0000000..fa922f4
--- /dev/null
+++ b/tests/data/TestVarfea-Bold.ufo/glyphs/space.glif
@@ -0,0 +1,13 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<glyph name="space" format="2">
+  <advance width="600"/>
+  <unicode hex="0020"/>
+  <outline>
+  </outline>
+  <lib>
+    <dict>
+      <key>com.schriftgestaltung.Glyphs.lastChange</key>
+      <string>2022/07/26 15:00:45</string>
+    </dict>
+  </lib>
+</glyph>
diff --git a/tests/data/TestVarfea-Bold.ufo/kerning.plist b/tests/data/TestVarfea-Bold.ufo/kerning.plist
new file mode 100644
index 0000000..6bf0bd2
--- /dev/null
+++ b/tests/data/TestVarfea-Bold.ufo/kerning.plist
@@ -0,0 +1,11 @@
+<?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>alef-ar.fina</key>
+    <dict>
+      <key>alef-ar.fina</key>
+      <integer>35</integer>
+    </dict>
+  </dict>
+</plist>
diff --git a/tests/data/TestVarfea-Bold.ufo/layercontents.plist b/tests/data/TestVarfea-Bold.ufo/layercontents.plist
new file mode 100644
index 0000000..b9c1a4f
--- /dev/null
+++ b/tests/data/TestVarfea-Bold.ufo/layercontents.plist
@@ -0,0 +1,10 @@
+<?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">
+  <array>
+    <array>
+      <string>public.default</string>
+      <string>glyphs</string>
+    </array>
+  </array>
+</plist>
diff --git a/tests/data/TestVarfea-Bold.ufo/lib.plist b/tests/data/TestVarfea-Bold.ufo/lib.plist
new file mode 100644
index 0000000..d4c4a8c
--- /dev/null
+++ b/tests/data/TestVarfea-Bold.ufo/lib.plist
@@ -0,0 +1,80 @@
+<?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>GSCornerRadius</key>
+    <integer>15</integer>
+    <key>GSOffsetHorizontal</key>
+    <integer>-30</integer>
+    <key>GSOffsetVertical</key>
+    <integer>-25</integer>
+    <key>com.github.googlei18n.ufo2ft.filters</key>
+    <array>
+      <dict>
+        <key>name</key>
+        <string>eraseOpenCorners</string>
+        <key>namespace</key>
+        <string>glyphsLib.filters</string>
+        <key>pre</key>
+        <true/>
+      </dict>
+    </array>
+    <key>com.schriftgestaltung.appVersion</key>
+    <string>3217</string>
+    <key>com.schriftgestaltung.customParameter.GSFont.DisplayStrings</key>
+    <array>
+      <string>اا</string>
+      <string>/dotabove-ar</string>
+    </array>
+    <key>com.schriftgestaltung.customParameter.GSFont.disablesAutomaticAlignment</key>
+    <false/>
+    <key>com.schriftgestaltung.customParameter.GSFont.useNiceNames</key>
+    <integer>1</integer>
+    <key>com.schriftgestaltung.customParameter.GSFontMaster.customValue</key>
+    <integer>0</integer>
+    <key>com.schriftgestaltung.customParameter.GSFontMaster.customValue1</key>
+    <integer>0</integer>
+    <key>com.schriftgestaltung.customParameter.GSFontMaster.customValue2</key>
+    <integer>0</integer>
+    <key>com.schriftgestaltung.customParameter.GSFontMaster.customValue3</key>
+    <integer>0</integer>
+    <key>com.schriftgestaltung.customParameter.GSFontMaster.iconName</key>
+    <string>Bold</string>
+    <key>com.schriftgestaltung.customParameter.GSFontMaster.weightValue</key>
+    <integer>1000</integer>
+    <key>com.schriftgestaltung.customParameter.GSFontMaster.widthValue</key>
+    <integer>100</integer>
+    <key>com.schriftgestaltung.fontMasterID</key>
+    <string>B1C208F5-A14F-4863-B7E7-1D4BAD4C88B2</string>
+    <key>com.schriftgestaltung.fontMasterOrder</key>
+    <integer>1</integer>
+    <key>com.schriftgestaltung.keyboardIncrement</key>
+    <integer>1</integer>
+    <key>com.schriftgestaltung.weight</key>
+    <string>Regular</string>
+    <key>com.schriftgestaltung.weightValue</key>
+    <integer>1000</integer>
+    <key>com.schriftgestaltung.width</key>
+    <string>Regular</string>
+    <key>com.schriftgestaltung.widthValue</key>
+    <integer>100</integer>
+    <key>public.glyphOrder</key>
+    <array>
+      <string>alef-ar.fina</string>
+      <string>peh-ar.init</string>
+      <string>space</string>
+      <string>dotabove-ar</string>
+    </array>
+    <key>public.postscriptNames</key>
+    <dict>
+      <key>alef-ar.fina</key>
+      <string>uniFE8E</string>
+      <key>dotabove-ar</key>
+      <string>dotabovear</string>
+      <key>peh-ar.init</key>
+      <string>uniFB58</string>
+      <key>peh-ar.init.BRACKET.varAlt01</key>
+      <string>uniFB58.BRACKET.varAlt01</string>
+    </dict>
+  </dict>
+</plist>
diff --git a/tests/data/TestVarfea-Bold.ufo/metainfo.plist b/tests/data/TestVarfea-Bold.ufo/metainfo.plist
new file mode 100644
index 0000000..7b8b34a
--- /dev/null
+++ b/tests/data/TestVarfea-Bold.ufo/metainfo.plist
@@ -0,0 +1,10 @@
+<?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>creator</key>
+    <string>com.github.fonttools.ufoLib</string>
+    <key>formatVersion</key>
+    <integer>3</integer>
+  </dict>
+</plist>
diff --git a/tests/data/TestVarfea-Regular.ufo/fontinfo.plist b/tests/data/TestVarfea-Regular.ufo/fontinfo.plist
new file mode 100644
index 0000000..9f3eadf
--- /dev/null
+++ b/tests/data/TestVarfea-Regular.ufo/fontinfo.plist
@@ -0,0 +1,48 @@
+<?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>600</integer>
+    <key>capHeight</key>
+    <integer>0</integer>
+    <key>descender</key>
+    <integer>-400</integer>
+    <key>familyName</key>
+    <string>TestVarfea</string>
+    <key>italicAngle</key>
+    <integer>0</integer>
+    <key>openTypeHeadCreated</key>
+    <string>2022/07/26 14:49:29</string>
+    <key>openTypeOS2Type</key>
+    <array>
+      <integer>3</integer>
+    </array>
+    <key>postscriptBlueValues</key>
+    <array>
+      <integer>-16</integer>
+      <integer>0</integer>
+      <integer>600</integer>
+      <integer>616</integer>
+    </array>
+    <key>postscriptOtherBlues</key>
+    <array>
+      <integer>-416</integer>
+      <integer>-400</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>0</integer>
+  </dict>
+</plist>
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 0000000..dfd5995
--- /dev/null
+++ b/tests/data/TestVarfea-Regular.ufo/glyphs/alef-ar.fina.glif
@@ -0,0 +1,31 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<glyph name="alef-ar.fina" format="2">
+  <advance width="600"/>
+  <unicode hex="0627"/>
+  <anchor x="299" y="97" name="entry"/>
+  <anchor x="211" y="730" name="top"/>
+  <outline>
+    <contour>
+      <point x="270" y="601" type="line"/>
+      <point x="270" y="173"/>
+      <point x="283" y="133"/>
+      <point x="321" y="118" type="curve" smooth="yes"/>
+      <point x="375" y="97"/>
+      <point x="403" y="105"/>
+      <point x="466" y="133" type="curve"/>
+      <point x="491" y="13" type="line"/>
+      <point x="427" y="-19"/>
+      <point x="381" y="-25"/>
+      <point x="336" y="-25" type="curve" smooth="yes"/>
+      <point x="139" y="-25"/>
+      <point x="160" y="121"/>
+      <point x="160" y="569" type="curve"/>
+    </contour>
+  </outline>
+  <lib>
+    <dict>
+      <key>com.schriftgestaltung.Glyphs.lastChange</key>
+      <string>2023/11/20 16:51:14</string>
+    </dict>
+  </lib>
+</glyph>
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 0000000..4572eab
--- /dev/null
+++ b/tests/data/TestVarfea-Regular.ufo/glyphs/contents.plist
@@ -0,0 +1,16 @@
+<?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>alef-ar.fina</key>
+    <string>alef-ar.fina.glif</string>
+    <key>dotabove-ar</key>
+    <string>dotabove-ar.glif</string>
+    <key>peh-ar.init</key>
+    <string>peh-ar.init.glif</string>
+    <key>peh-ar.init.BRACKET.varAlt01</key>
+    <string>peh-ar.init.B_R_A_C_K_E_T_.varA_lt01.glif</string>
+    <key>space</key>
+    <string>space.glif</string>
+  </dict>
+</plist>
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 0000000..d9ec358
--- /dev/null
+++ b/tests/data/TestVarfea-Regular.ufo/glyphs/dotabove-ar.glif
@@ -0,0 +1,31 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<glyph name="dotabove-ar" format="2">
+  <anchor x="100" y="320" name="_top"/>
+  <outline>
+    <contour>
+      <point x="96" y="232" type="curve"/>
+      <point x="104" y="232" type="line"/>
+      <point x="160" y="271"/>
+      <point x="187" y="303"/>
+      <point x="187" y="326" type="curve" smooth="yes"/>
+      <point x="187" y="349"/>
+      <point x="170" y="372"/>
+      <point x="109" y="411" type="curve"/>
+      <point x="100" y="411" type="line"/>
+      <point x="83" y="400"/>
+      <point x="30" y="341"/>
+      <point x="13" y="315" type="curve"/>
+      <point x="13" y="306" type="line"/>
+      <point x="40" y="285"/>
+      <point x="68" y="260"/>
+    </contour>
+  </outline>
+  <lib>
+    <dict>
+      <key>com.schriftgestaltung.Glyphs.lastChange</key>
+      <string>2023/11/20 16:50:50</string>
+      <key>com.schriftgestaltung.Glyphs.originalWidth</key>
+      <integer>300</integer>
+    </dict>
+  </lib>
+</glyph>
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 0000000..e4d188f
--- /dev/null
+++ b/tests/data/TestVarfea-Regular.ufo/glyphs/layerinfo.plist
@@ -0,0 +1,19 @@
+<?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>lib</key>
+    <dict>
+      <key>com.schriftgestaltung.layerId</key>
+      <string>m01</string>
+      <key>com.schriftgestaltung.layerOrderInGlyph.alef-ar.fina</key>
+      <integer>0</integer>
+      <key>com.schriftgestaltung.layerOrderInGlyph.dotabove-ar</key>
+      <integer>0</integer>
+      <key>com.schriftgestaltung.layerOrderInGlyph.peh-ar.init</key>
+      <integer>0</integer>
+      <key>com.schriftgestaltung.layerOrderInGlyph.space</key>
+      <integer>0</integer>
+    </dict>
+  </dict>
+</plist>
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 0000000..26c13c3
--- /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 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<glyph name="peh-ar.init.BRACKET.varAlt01" format="2">
+  <advance width="600"/>
+  <anchor x="89" y="53" name="exit"/>
+  <outline>
+    <contour>
+      <point x="325" y="290" type="curve"/>
+      <point x="490" y="215" type="line"/>
+      <point x="469" y="-10"/>
+      <point x="412" y="-54"/>
+      <point x="266" y="-54" type="curve" smooth="yes"/>
+      <point x="161" y="-54"/>
+      <point x="90" y="-27"/>
+      <point x="67" y="22" type="curve"/>
+      <point x="67" y="130" type="line"/>
+      <point x="137" y="80"/>
+      <point x="173" y="67"/>
+      <point x="241" y="67" type="curve" smooth="yes"/>
+      <point x="291" y="67"/>
+      <point x="315" y="118"/>
+    </contour>
+    <contour>
+      <point x="284" y="-149" type="curve" smooth="yes"/>
+      <point x="194" y="-235" type="line" smooth="yes"/>
+      <point x="188" y="-241"/>
+      <point x="188" y="-250"/>
+      <point x="194" y="-257" type="curve" smooth="yes"/>
+      <point x="280" y="-347" type="line" smooth="yes"/>
+      <point x="286" y="-354"/>
+      <point x="296" y="-353"/>
+      <point x="302" y="-347" type="curve" smooth="yes"/>
+      <point x="369" y="-283" type="line"/>
+      <point x="430" y="-347" type="line" smooth="yes"/>
+      <point x="436" y="-354"/>
+      <point x="446" y="-353"/>
+      <point x="452" y="-347" type="curve" smooth="yes"/>
+      <point x="542" y="-261" type="line" smooth="yes"/>
+      <point x="548" y="-255"/>
+      <point x="548" y="-246"/>
+      <point x="542" y="-239" type="curve" smooth="yes"/>
+      <point x="456" y="-149" type="line" smooth="yes"/>
+      <point x="450" y="-142"/>
+      <point x="440" y="-143"/>
+      <point x="434" y="-149" type="curve" smooth="yes"/>
+      <point x="367" y="-213" type="line"/>
+      <point x="306" y="-149" type="line" smooth="yes"/>
+      <point x="300" y="-142"/>
+      <point x="290" y="-143"/>
+    </contour>
+    <contour>
+      <point x="354" y="-349" type="curve" smooth="yes"/>
+      <point x="264" y="-435" type="line" smooth="yes"/>
+      <point x="258" y="-441"/>
+      <point x="258" y="-450"/>
+      <point x="264" y="-457" type="curve" smooth="yes"/>
+      <point x="350" y="-547" type="line" smooth="yes"/>
+      <point x="356" y="-554"/>
+      <point x="366" y="-553"/>
+      <point x="372" y="-547" type="curve" smooth="yes"/>
+      <point x="462" y="-461" type="line" smooth="yes"/>
+      <point x="468" y="-455"/>
+      <point x="468" y="-446"/>
+      <point x="462" y="-439" type="curve" smooth="yes"/>
+      <point x="376" y="-349" type="line" smooth="yes"/>
+      <point x="370" y="-342"/>
+      <point x="360" y="-343"/>
+    </contour>
+  </outline>
+  <lib>
+    <dict>
+      <key>com.schriftgestaltung.Glyphs._originalLayerName</key>
+      <string>26 Jul 22, 15:58</string>
+      <key>com.schriftgestaltung.Glyphs.lastChange</key>
+      <string>2022/07/27 08:01:34</string>
+    </dict>
+  </lib>
+</glyph>
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 0000000..03c6ba3
--- /dev/null
+++ b/tests/data/TestVarfea-Regular.ufo/glyphs/peh-ar.init.glif
@@ -0,0 +1,84 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<glyph name="peh-ar.init" format="2">
+  <advance width="600"/>
+  <unicode hex="067E"/>
+  <anchor x="161" y="54" name="exit"/>
+  <outline>
+    <contour>
+      <point x="378" y="268" type="curve"/>
+      <point x="466" y="268" type="line"/>
+      <point x="450" y="61"/>
+      <point x="400" y="-33"/>
+      <point x="291" y="-56" type="curve" smooth="yes"/>
+      <point x="165" y="-84"/>
+      <point x="107" y="-64"/>
+      <point x="67" y="22" type="curve"/>
+      <point x="67" y="130" type="line"/>
+      <point x="124" y="89"/>
+      <point x="185" y="67"/>
+      <point x="241" y="67" type="curve" smooth="yes"/>
+      <point x="332" y="67"/>
+      <point x="370" y="122"/>
+    </contour>
+    <contour>
+      <point x="254" y="-149" type="curve" smooth="yes"/>
+      <point x="164" y="-235" type="line" smooth="yes"/>
+      <point x="158" y="-241"/>
+      <point x="158" y="-250"/>
+      <point x="164" y="-257" type="curve" smooth="yes"/>
+      <point x="250" y="-347" type="line" smooth="yes"/>
+      <point x="256" y="-354"/>
+      <point x="265" y="-354"/>
+      <point x="272" y="-347" type="curve" smooth="yes"/>
+      <point x="362" y="-261" type="line" smooth="yes"/>
+      <point x="368" y="-255"/>
+      <point x="368" y="-246"/>
+      <point x="362" y="-239" type="curve" smooth="yes"/>
+      <point x="276" y="-149" type="line" smooth="yes"/>
+      <point x="270" y="-142"/>
+      <point x="261" y="-142"/>
+    </contour>
+    <contour>
+      <point x="474" y="-149" type="curve" smooth="yes"/>
+      <point x="384" y="-235" type="line" smooth="yes"/>
+      <point x="378" y="-241"/>
+      <point x="378" y="-250"/>
+      <point x="384" y="-257" type="curve" smooth="yes"/>
+      <point x="470" y="-347" type="line" smooth="yes"/>
+      <point x="476" y="-354"/>
+      <point x="485" y="-354"/>
+      <point x="492" y="-347" type="curve" smooth="yes"/>
+      <point x="582" y="-261" type="line" smooth="yes"/>
+      <point x="588" y="-255"/>
+      <point x="588" y="-246"/>
+      <point x="582" y="-239" type="curve" smooth="yes"/>
+      <point x="496" y="-149" type="line" smooth="yes"/>
+      <point x="490" y="-142"/>
+      <point x="481" y="-142"/>
+    </contour>
+    <contour>
+      <point x="354" y="-349" type="curve" smooth="yes"/>
+      <point x="264" y="-435" type="line" smooth="yes"/>
+      <point x="258" y="-441"/>
+      <point x="258" y="-450"/>
+      <point x="264" y="-457" type="curve" smooth="yes"/>
+      <point x="350" y="-547" type="line" smooth="yes"/>
+      <point x="356" y="-554"/>
+      <point x="365" y="-554"/>
+      <point x="372" y="-547" type="curve" smooth="yes"/>
+      <point x="462" y="-461" type="line" smooth="yes"/>
+      <point x="468" y="-455"/>
+      <point x="468" y="-446"/>
+      <point x="462" y="-439" type="curve" smooth="yes"/>
+      <point x="376" y="-349" type="line" smooth="yes"/>
+      <point x="370" y="-342"/>
+      <point x="361" y="-342"/>
+    </contour>
+  </outline>
+  <lib>
+    <dict>
+      <key>com.schriftgestaltung.Glyphs.lastChange</key>
+      <string>2022/07/27 08:01:34</string>
+    </dict>
+  </lib>
+</glyph>
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 0000000..ad61b90
--- /dev/null
+++ b/tests/data/TestVarfea-Regular.ufo/glyphs/space.glif
@@ -0,0 +1,13 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<glyph name="space" format="2">
+  <advance width="200"/>
+  <unicode hex="0020"/>
+  <outline>
+  </outline>
+  <lib>
+    <dict>
+      <key>com.schriftgestaltung.Glyphs.lastChange</key>
+      <string>2022/07/26 15:00:45</string>
+    </dict>
+  </lib>
+</glyph>
diff --git a/tests/data/TestVarfea-Regular.ufo/kerning.plist b/tests/data/TestVarfea-Regular.ufo/kerning.plist
new file mode 100644
index 0000000..696a1f4
--- /dev/null
+++ b/tests/data/TestVarfea-Regular.ufo/kerning.plist
@@ -0,0 +1,11 @@
+<?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>alef-ar.fina</key>
+    <dict>
+      <key>alef-ar.fina</key>
+      <integer>15</integer>
+    </dict>
+  </dict>
+</plist>
diff --git a/tests/data/TestVarfea-Regular.ufo/layercontents.plist b/tests/data/TestVarfea-Regular.ufo/layercontents.plist
new file mode 100644
index 0000000..b9c1a4f
--- /dev/null
+++ b/tests/data/TestVarfea-Regular.ufo/layercontents.plist
@@ -0,0 +1,10 @@
+<?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">
+  <array>
+    <array>
+      <string>public.default</string>
+      <string>glyphs</string>
+    </array>
+  </array>
+</plist>
diff --git a/tests/data/TestVarfea-Regular.ufo/lib.plist b/tests/data/TestVarfea-Regular.ufo/lib.plist
new file mode 100644
index 0000000..0b5415f
--- /dev/null
+++ b/tests/data/TestVarfea-Regular.ufo/lib.plist
@@ -0,0 +1,74 @@
+<?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>com.github.googlei18n.ufo2ft.filters</key>
+    <array>
+      <dict>
+        <key>name</key>
+        <string>eraseOpenCorners</string>
+        <key>namespace</key>
+        <string>glyphsLib.filters</string>
+        <key>pre</key>
+        <true/>
+      </dict>
+    </array>
+    <key>com.schriftgestaltung.appVersion</key>
+    <string>3217</string>
+    <key>com.schriftgestaltung.customParameter.GSFont.DisplayStrings</key>
+    <array>
+      <string>اا</string>
+      <string>/dotabove-ar</string>
+    </array>
+    <key>com.schriftgestaltung.customParameter.GSFont.disablesAutomaticAlignment</key>
+    <false/>
+    <key>com.schriftgestaltung.customParameter.GSFont.useNiceNames</key>
+    <integer>1</integer>
+    <key>com.schriftgestaltung.customParameter.GSFontMaster.customValue</key>
+    <integer>0</integer>
+    <key>com.schriftgestaltung.customParameter.GSFontMaster.customValue1</key>
+    <integer>0</integer>
+    <key>com.schriftgestaltung.customParameter.GSFontMaster.customValue2</key>
+    <integer>0</integer>
+    <key>com.schriftgestaltung.customParameter.GSFontMaster.customValue3</key>
+    <integer>0</integer>
+    <key>com.schriftgestaltung.customParameter.GSFontMaster.iconName</key>
+    <string></string>
+    <key>com.schriftgestaltung.customParameter.GSFontMaster.weightValue</key>
+    <integer>100</integer>
+    <key>com.schriftgestaltung.customParameter.GSFontMaster.widthValue</key>
+    <integer>100</integer>
+    <key>com.schriftgestaltung.fontMasterID</key>
+    <string>m01</string>
+    <key>com.schriftgestaltung.fontMasterOrder</key>
+    <integer>0</integer>
+    <key>com.schriftgestaltung.keyboardIncrement</key>
+    <integer>1</integer>
+    <key>com.schriftgestaltung.weight</key>
+    <string>Regular</string>
+    <key>com.schriftgestaltung.weightValue</key>
+    <integer>100</integer>
+    <key>com.schriftgestaltung.width</key>
+    <string>Regular</string>
+    <key>com.schriftgestaltung.widthValue</key>
+    <integer>100</integer>
+    <key>public.glyphOrder</key>
+    <array>
+      <string>alef-ar.fina</string>
+      <string>peh-ar.init</string>
+      <string>space</string>
+      <string>dotabove-ar</string>
+    </array>
+    <key>public.postscriptNames</key>
+    <dict>
+      <key>alef-ar.fina</key>
+      <string>uniFE8E</string>
+      <key>dotabove-ar</key>
+      <string>dotabovear</string>
+      <key>peh-ar.init</key>
+      <string>uniFB58</string>
+      <key>peh-ar.init.BRACKET.varAlt01</key>
+      <string>uniFB58.BRACKET.varAlt01</string>
+    </dict>
+  </dict>
+</plist>
diff --git a/tests/data/TestVarfea-Regular.ufo/metainfo.plist b/tests/data/TestVarfea-Regular.ufo/metainfo.plist
new file mode 100644
index 0000000..7b8b34a
--- /dev/null
+++ b/tests/data/TestVarfea-Regular.ufo/metainfo.plist
@@ -0,0 +1,10 @@
+<?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>creator</key>
+    <string>com.github.fonttools.ufoLib</string>
+    <key>formatVersion</key>
+    <integer>3</integer>
+  </dict>
+</plist>
diff --git a/tests/data/TestVarfea.designspace b/tests/data/TestVarfea.designspace
new file mode 100644
index 0000000..aa7a1a6
--- /dev/null
+++ b/tests/data/TestVarfea.designspace
@@ -0,0 +1,30 @@
+<?xml version='1.0' encoding='UTF-8'?>
+<designspace format="5.0">
+  <axes>
+    <axis tag="wght" name="Weight" minimum="100" maximum="1000" default="100"/>
+  </axes>
+  <rules>
+    <rule name="BRACKET.Weight_600_1000">
+      <conditionset>
+        <condition name="Weight" minimum="600" maximum="1000"/>
+      </conditionset>
+      <sub name="peh-ar.init" with="peh-ar.init.BRACKET.varAlt01"/>
+    </rule>
+  </rules>
+  <sources>
+    <source filename="TestVarfea-Regular.ufo" name="TestVarfea Regular" familyname="TestVarfea" stylename="Regular">
+      <lib copy="1"/>
+      <groups copy="1"/>
+      <features copy="1"/>
+      <info copy="1"/>
+      <location>
+        <dimension name="Weight" xvalue="100"/>
+      </location>
+    </source>
+    <source filename="TestVarfea-Bold.ufo" name="TestVarfea Bold" familyname="TestVarfea" stylename="Bold">
+      <location>
+        <dimension name="Weight" xvalue="1000"/>
+      </location>
+    </source>
+  </sources>
+</designspace>
diff --git a/tests/data/TestVarfea.glyphs b/tests/data/TestVarfea.glyphs
new file mode 100644
index 0000000..5c0e945
--- /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 a7ad531..b2a8804 100644
--- a/tests/data/TestVariableFont-CFF2-cffsubr.ttx
+++ b/tests/data/TestVariableFont-CFF2-cffsubr.ttx
@@ -328,7 +328,7 @@
         <!-- RegionCount=1 -->
         <Region index="0">
           <VarRegionAxis index="0">
-            <StartCoord value="0.0"/>
+            <StartCoord value="0.36365"/>
             <PeakCoord value="1.0"/>
             <EndCoord value="1.0"/>
           </VarRegionAxis>
diff --git a/tests/data/TestVariableFont-CFF2-post3.ttx b/tests/data/TestVariableFont-CFF2-post3.ttx
index 163c07f..d8f17e0 100644
--- a/tests/data/TestVariableFont-CFF2-post3.ttx
+++ b/tests/data/TestVariableFont-CFF2-post3.ttx
@@ -305,7 +305,7 @@
         <!-- RegionCount=1 -->
         <Region index="0">
           <VarRegionAxis index="0">
-            <StartCoord value="0.0"/>
+            <StartCoord value="0.36365"/>
             <PeakCoord value="1.0"/>
             <EndCoord value="1.0"/>
           </VarRegionAxis>
diff --git a/tests/data/TestVariableFont-CFF2-useProductionNames.ttx b/tests/data/TestVariableFont-CFF2-useProductionNames.ttx
index b78c88c..9bf8210 100644
--- a/tests/data/TestVariableFont-CFF2-useProductionNames.ttx
+++ b/tests/data/TestVariableFont-CFF2-useProductionNames.ttx
@@ -322,7 +322,7 @@
         <!-- RegionCount=1 -->
         <Region index="0">
           <VarRegionAxis index="0">
-            <StartCoord value="0.0"/>
+            <StartCoord value="0.36365"/>
             <PeakCoord value="1.0"/>
             <EndCoord value="1.0"/>
           </VarRegionAxis>
diff --git a/tests/data/TestVariableFont-CFF2.ttx b/tests/data/TestVariableFont-CFF2.ttx
index cfb50b2..c53ebc9 100644
--- a/tests/data/TestVariableFont-CFF2.ttx
+++ b/tests/data/TestVariableFont-CFF2.ttx
@@ -319,7 +319,7 @@
         <!-- RegionCount=1 -->
         <Region index="0">
           <VarRegionAxis index="0">
-            <StartCoord value="0.0"/>
+            <StartCoord value="0.36365"/>
             <PeakCoord value="1.0"/>
             <EndCoord value="1.0"/>
           </VarRegionAxis>
diff --git a/tests/data/TestVariableFont-TTF-post3.ttx b/tests/data/TestVariableFont-TTF-post3.ttx
index 6b5fa50..aa21f3e 100644
--- a/tests/data/TestVariableFont-TTF-post3.ttx
+++ b/tests/data/TestVariableFont-TTF-post3.ttx
@@ -308,7 +308,7 @@
         <!-- RegionCount=1 -->
         <Region index="0">
           <VarRegionAxis index="0">
-            <StartCoord value="0.0"/>
+            <StartCoord value="0.36365"/>
             <PeakCoord value="1.0"/>
             <EndCoord value="1.0"/>
           </VarRegionAxis>
diff --git a/tests/data/TestVariableFont-TTF-useProductionNames.ttx b/tests/data/TestVariableFont-TTF-useProductionNames.ttx
index fff0a01..57466c5 100644
--- a/tests/data/TestVariableFont-TTF-useProductionNames.ttx
+++ b/tests/data/TestVariableFont-TTF-useProductionNames.ttx
@@ -325,7 +325,7 @@
         <!-- RegionCount=1 -->
         <Region index="0">
           <VarRegionAxis index="0">
-            <StartCoord value="0.0"/>
+            <StartCoord value="0.36365"/>
             <PeakCoord value="1.0"/>
             <EndCoord value="1.0"/>
           </VarRegionAxis>
diff --git a/tests/data/TestVariableFont-TTF.ttx b/tests/data/TestVariableFont-TTF.ttx
index 63ff72b..a7504b6 100644
--- a/tests/data/TestVariableFont-TTF.ttx
+++ b/tests/data/TestVariableFont-TTF.ttx
@@ -322,7 +322,7 @@
         <!-- RegionCount=1 -->
         <Region index="0">
           <VarRegionAxis index="0">
-            <StartCoord value="0.0"/>
+            <StartCoord value="0.36365"/>
             <PeakCoord value="1.0"/>
             <EndCoord value="1.0"/>
           </VarRegionAxis>
diff --git a/tests/featureWriters/variableFeatureWriter_test.py b/tests/featureWriters/variableFeatureWriter_test.py
new file mode 100644
index 0000000..92092c6
--- /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 <anchor (wght=100:100 wght=1000:125) (wght=100:320 wght=1000:416)> @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
+                    <anchor (wght=100:211 wght=1000:214) (wght=100:730 wght=1000:797)> mark @MC_top;
+            } mark2base;
+
+        } mark;
+
+        feature curs {
+            lookup curs {
+                lookupflag RightToLeft IgnoreMarks;
+                pos cursive alef-ar.fina <anchor (wght=100:299 wght=1000:330) (wght=100:97 wght=1000:115)> <anchor NULL>;
+                pos cursive peh-ar.init <anchor NULL> <anchor (wght=100:161 wght=1000:73) (wght=100:54 wght=1000:89)>;
+                pos cursive peh-ar.init.BRACKET.varAlt01 <anchor NULL> <anchor (wght=100:89 wght=1000:73) (wght=100:53 wght=1000:85)>;
+            } curs;
+
+        } curs;
+"""  # noqa: B950
+    )
diff --git a/tests/integration_test.py b/tests/integration_test.py
index 05c2ba0..0eb2a03 100644
--- a/tests/integration_test.py
+++ b/tests/integration_test.py
@@ -4,6 +4,7 @@ import os
 import re
 import sys
 from pathlib import Path
+from textwrap import dedent
 
 import pytest
 from fontTools.pens.boundsPen import BoundsPen
@@ -241,9 +242,23 @@ class IntegrationTest:
         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 <anchor -2 465> @MC_top;
+
+            feature liga {
+                sub a e s s by s;
+            } liga;
+
+            feature mark {
+                lookup mark2base {
+                    pos base e
+                        <anchor (wght=350:314 wght=450:314 wght=625:315) (wght=350:556 wght=450:556 wght=625:644)> mark @MC_top;
+                } mark2base;
+
+            } mark;
+        """  # noqa: B950
+        )
 
     @pytest.mark.parametrize(
         "output_format, options, expected_ttx",