Repository: googlefonts/ufo2ft
Pull request #593 (2022-02-28 16:16:08)

Add dotted circle filter
Google Fonts' fontbakery tool has recently made it mandatory for fonts supporting complex scripts to contain a dotted circle (U+25CC) glyph, and for this glyph to have appropriate anchors for all attachable marks.

This is a boring job to do manually, and both the drawing and the anchor positioning can be automated.

This PR introduces a filter which can be called at font build time (`--filter "ufo2ft.filters.dottedCircleFilter::DottedCircleFilter(pre=True)"`) which checks for the presence of a dotted circle glyph, adds one if there is not one present, computes average anchor positions for attaching anchors and adds any missing anchors to the dotted circle glyph.

Example:

```
$ fontmake -o ttf -g src/NotoNastaliqUrdu.glyphs --filter "ufo2ft.filters.dottedCircleFilter::DottedCircleFilter(pre=True,dots=10)"
...
$ shape master_ttf/NotoNastaliqUrdu-Regular.ttf '◌ِّ'
[uni25CC=0+727|ShaddaNS=0@-312,-97+0|KasraNS=0@-368,-164+0]
```
![shape](https://user-images.githubusercontent.com/106728/156019354-b6cf6ec4-50bf-4c32-9593-fbd4461d8a79.png)


madig: Very nice idea, but could use some tests! 😀
simoncozens: I do plan to add some tests.

Patch:
diff --git a/Lib/ufo2ft/__init__.py b/Lib/ufo2ft/__init__.py
index c80757e..9bd77f0 100644
--- a/Lib/ufo2ft/__init__.py
+++ b/Lib/ufo2ft/__init__.py
@@ -1,7 +1,9 @@
 import logging
+import os
 from enum import IntEnum
 
 from fontTools import varLib
+from fontTools.otlLib.optimize.gpos import GPOS_COMPACT_MODE_ENV_KEY
 
 from ufo2ft.constants import SPARSE_OTF_MASTER_TABLES, SPARSE_TTF_MASTER_TABLES
 from ufo2ft.featureCompiler import (
@@ -537,17 +539,25 @@ def compileVariableTTF(designSpaceDoc, **kwargs):
     excludeVariationTables = kwargs.pop("excludeVariationTables")
     optimizeGvar = kwargs.pop("optimizeGvar")
 
-    ttfDesignSpace = compileInterpolatableTTFsFromDS(
-        designSpaceDoc,
-        **{
-            **kwargs,
-            **dict(
-                useProductionNames=False,  # will rename glyphs after varfont is built
-                # No need to post-process intermediate fonts.
-                postProcessorClass=None,
-            ),
-        },
-    )
+    # FIXME: Hack until we get a fontTools config module. Disable GPOS
+    # compaction while building masters because the compaction will be undone
+    # anyway by varLib merge and then done again on the VF
+    gpos_compact_value = os.environ.pop(GPOS_COMPACT_MODE_ENV_KEY, None)
+    try:
+        ttfDesignSpace = compileInterpolatableTTFsFromDS(
+            designSpaceDoc,
+            **{
+                **kwargs,
+                **dict(
+                    useProductionNames=False,  # will rename glyphs after varfont is built
+                    # No need to post-process intermediate fonts.
+                    postProcessorClass=None,
+                ),
+            },
+        )
+    finally:
+        if gpos_compact_value is not None:
+            os.environ[GPOS_COMPACT_MODE_ENV_KEY] = gpos_compact_value
 
     logger.info("Building variable TTF font")
 
@@ -596,17 +606,25 @@ def compileVariableCFF2(designSpaceDoc, **kwargs):
 
     excludeVariationTables = kwargs.pop("excludeVariationTables")
 
-    otfDesignSpace = compileInterpolatableOTFsFromDS(
-        designSpaceDoc,
-        **{
-            **kwargs,
-            **dict(
-                useProductionNames=False,  # will rename glyphs after varfont is built
-                # No need to post-process intermediate fonts.
-                postProcessorClass=None,
-            ),
-        },
-    )
+    # FIXME: Hack until we get a fontTools config module. Disable GPOS
+    # compaction while building masters because the compaction will be undone
+    # anyway by varLib merge and then done again on the VF
+    gpos_compact_value = os.environ.pop(GPOS_COMPACT_MODE_ENV_KEY, None)
+    try:
+        otfDesignSpace = compileInterpolatableOTFsFromDS(
+            designSpaceDoc,
+            **{
+                **kwargs,
+                **dict(
+                    useProductionNames=False,  # will rename glyphs after varfont is built
+                    # No need to post-process intermediate fonts.
+                    postProcessorClass=None,
+                ),
+            },
+        )
+    finally:
+        if gpos_compact_value is not None:
+            os.environ[GPOS_COMPACT_MODE_ENV_KEY] = gpos_compact_value
 
     logger.info("Building variable CFF2 font")
 
diff --git a/Lib/ufo2ft/featureCompiler.py b/Lib/ufo2ft/featureCompiler.py
index 05e151f..aa6d90d 100644
--- a/Lib/ufo2ft/featureCompiler.py
+++ b/Lib/ufo2ft/featureCompiler.py
@@ -17,6 +17,7 @@ from ufo2ft.featureWriters import (
     KernFeatureWriter,
     MarkFeatureWriter,
     ast,
+    isValidFeatureWriter,
     loadFeatureWriters,
 )
 
@@ -165,10 +166,17 @@ class FeatureCompiler(BaseFeatureCompiler):
               under the key "com.github.googlei18n.ufo2ft.featureWriters"
               (see loadFeatureWriters).
             - if that is not found, the default list of writers will be used:
-              [KernFeatureWriter, MarkFeatureWriter]. This generates "kern"
-              (or "dist" for Indic scripts), "mark" and "mkmk" features.
+              (see FeatureCompiler.defaultFeatureWriters, and the individual
+              feature writer classes for the list of features generated).
             If the featureWriters list is empty, no automatic feature is
             generated and only pre-existing features are compiled.
+            The ``featureWriters`` parameter overrides both the writers from
+            the UFO lib and the default writers list. To extend instead of
+            replace the latter, the list can contain a special value ``...``
+            (i.e. the ``ellipsis`` singleton, not the str literal '...')
+            which gets replaced by either the UFO.lib writers or the default
+            ones; thus one can insert additional writers either before or after
+            these.
         """
         BaseFeatureCompiler.__init__(self, ufo, ttFont, glyphSet)
 
@@ -184,10 +192,41 @@ class FeatureCompiler(BaseFeatureCompiler):
                 stacklevel=2,
             )
 
+    def _load_custom_feature_writers(self, featureWriters=None):
+        # Args:
+        #   ufo: Font
+        #   featureWriters: Optional[List[Union[FeatureWriter, EllipsisType]]])
+        # Returns: List[FeatureWriter]
+
+        # by default, load the feature writers from the lib or the default ones;
+        # ellipsis is used as a placeholder so one can optionally insert additional
+        # featureWriters=[w1, ..., w2] either before or after these, or override
+        # them by omitting the ellipsis.
+        if featureWriters is None:
+            featureWriters = [...]
+        result = []
+        seen_ellipsis = False
+        for writer in featureWriters:
+            if writer is ...:
+                if seen_ellipsis:
+                    raise ValueError("ellipsis not allowed more than once")
+                writers = loadFeatureWriters(self.ufo)
+                if writers is not None:
+                    result.extend(writers)
+                else:
+                    result.extend(self.defaultFeatureWriters)
+                seen_ellipsis = True
+            else:
+                klass = writer if isclass(writer) else type(writer)
+                if not isValidFeatureWriter(klass):
+                    raise TypeError(f"Invalid feature writer: {writer!r}")
+                result.append(writer)
+        return result
+
     def initFeatureWriters(self, featureWriters=None):
         """Initialize feature writer classes as specified in the UFO lib.
-        If none are defined in the UFO, the default feature writers are used:
-        currently, KernFeatureWriter and MarkFeatureWriter.
+        If none are defined in the UFO, the default feature writers are used
+        (see FeatureCompiler.defaultFeatureWriters).
         The 'featureWriters' argument can be used to override these.
         The method sets the `self.featureWriters` attribute with the list of
         writers.
@@ -197,10 +236,7 @@ class FeatureCompiler(BaseFeatureCompiler):
         used in the subsequent feature writers to resolve substitutions from
         glyphs with unicodes to their alternates.
         """
-        if featureWriters is None:
-            featureWriters = loadFeatureWriters(self.ufo)
-            if featureWriters is None:
-                featureWriters = self.defaultFeatureWriters
+        featureWriters = self._load_custom_feature_writers(featureWriters)
 
         gsubWriters = []
         others = []
diff --git a/Lib/ufo2ft/featureWriters/ast.py b/Lib/ufo2ft/featureWriters/ast.py
index ace7577..6fa8825 100644
--- a/Lib/ufo2ft/featureWriters/ast.py
+++ b/Lib/ufo2ft/featureWriters/ast.py
@@ -209,9 +209,9 @@ def getGDEFGlyphClasses(feaLib):
     """Return GDEF GlyphClassDef base/mark/ligature/component glyphs, or
     None if no GDEF table is defined in the feature file.
     """
-    for st in feaLib.statements:
-        if isinstance(st, ast.TableBlock) and st.name == "GDEF":
-            for st in st.statements:
+    for s in feaLib.statements:
+        if isinstance(s, ast.TableBlock) and s.name == "GDEF":
+            for st in s.statements:
                 if isinstance(st, ast.GlyphClassDefStatement):
                     return _GDEFGlyphClasses(
                         frozenset(st.baseGlyphs.glyphSet())
diff --git a/Lib/ufo2ft/featureWriters/markFeatureWriter.py b/Lib/ufo2ft/featureWriters/markFeatureWriter.py
index ba16b77..473541e 100644
--- a/Lib/ufo2ft/featureWriters/markFeatureWriter.py
+++ b/Lib/ufo2ft/featureWriters/markFeatureWriter.py
@@ -201,15 +201,15 @@ def colorGraph(adjacency):
     """
     # Basic implementation
     # https://en.wikipedia.org/wiki/Greedy_coloring
-    color = dict()
+    colors = dict()
     # Sorted for reproducibility, probably not the optimal vertex order
     for node in sorted(adjacency):
         usedNeighbourColors = {
-            color[neighbour] for neighbour in adjacency[node] if neighbour in color
+            colors[neighbour] for neighbour in adjacency[node] if neighbour in colors
         }
-        color[node] = firstAvailable(usedNeighbourColors)
+        colors[node] = firstAvailable(usedNeighbourColors)
     groups = defaultdict(list)
-    for node, color in color.items():
+    for node, color in colors.items():
         groups[color].append(node)
     return list(groups.values())
 
diff --git a/Lib/ufo2ft/filters/__init__.py b/Lib/ufo2ft/filters/__init__.py
index e43223c..3e7a332 100644
--- a/Lib/ufo2ft/filters/__init__.py
+++ b/Lib/ufo2ft/filters/__init__.py
@@ -9,6 +9,7 @@ from .base import BaseFilter
 from .cubicToQuadratic import CubicToQuadraticFilter
 from .decomposeComponents import DecomposeComponentsFilter
 from .decomposeTransformedComponents import DecomposeTransformedComponentsFilter
+from .dottedCircleFilter import DottedCircleFilter
 from .explodeColorLayerGlyphs import ExplodeColorLayerGlyphsFilter
 from .flattenComponents import FlattenComponentsFilter
 from .propagateAnchors import PropagateAnchorsFilter
@@ -21,6 +22,7 @@ __all__ = [
     "CubicToQuadraticFilter",
     "DecomposeComponentsFilter",
     "DecomposeTransformedComponentsFilter",
+    "DottedCircleFilter",
     "ExplodeColorLayerGlyphsFilter",
     "FlattenComponentsFilter",
     "PropagateAnchorsFilter",
@@ -85,7 +87,7 @@ def isValidFilter(klass):
     a '__call__' (bound method), with the signature matching the same method
     from the BaseFilter class:
 
-           def __call__(self, font, feaFile, compiler=None)
+           def __call__(self, font, glyphSet=None)
     """
     if not isclass(klass):
         logger.error(f"{klass!r} is not a class")
diff --git a/Lib/ufo2ft/filters/dottedCircleFilter.py b/Lib/ufo2ft/filters/dottedCircleFilter.py
new file mode 100644
index 0000000..98cbaef
--- /dev/null
+++ b/Lib/ufo2ft/filters/dottedCircleFilter.py
@@ -0,0 +1,266 @@
+"""
+Dotted Circle Filter
+
+This filter checks whether a font contains a glyph for U+25CC (DOTTED CIRCLE),
+which is inserted by complex shapers to display mark glyphs which have no
+associated base glyph, usually as a result of broken clusters but also for
+pedagogical reasons. (For example, to display the marks in a table of glyphs.)
+
+If no dotted circle glyph is present in the font, then one is drawn and added.
+
+Next, the filter creates any additional anchors for the dotted circle glyph to
+ensure that all marks can be attached to it. It does this by gathering a list
+of anchors, finding the set of base glyphs for each anchor, computing the
+average position of the anchor on the base glyph (relative to the glyph's width),
+and then creating an anchor at that average position on the dotted circle glyph.
+
+The filter must be run as a "pre" filter. This can be done from the command
+line like so::
+
+    fontmake -o ttf -g MyFont.glyphs --filter "DottedCircleFilter(pre=True)"
+
+or in the ``lib.plist`` file of a UFO::
+
+    <key>com.github.googlei18n.ufo2ft.filters</key>
+    <array>
+      <dict>
+        <key>name</key>
+        <string>DottedCircleFilter</string>
+        <key>pre</key>
+        <true/>
+      </dict>
+    </array>
+
+The filter supports the following options:
+
+margin
+    When drawing a dotted circle, the vertical space in units around the dotted
+    circle.
+sidebearings
+    When drawing a dotted circle, additional horizontal space in units around
+    the dotted circle.
+dots
+    Number of dots in the circle.
+
+"""
+import logging
+import math
+from statistics import mean
+
+from fontTools.misc.fixedTools import otRound
+from ufoLib2.objects import Glyph
+
+from ufo2ft.constants import OPENTYPE_CATEGORIES_KEY
+from ufo2ft.featureCompiler import parseLayoutFeatures
+from ufo2ft.featureWriters import ast
+from ufo2ft.filters import BaseFilter
+from ufo2ft.util import _GlyphSet, _LazyFontName
+
+logger = logging.getLogger(__name__)
+
+DO_NOTHING = -1  # Sentinel value (not a valid glyph name)
+
+# Length of cubic Bezier handle used when drawing quarter circles.
+# See https://pomax.github.io/bezierinfo/#circles_cubic
+CIRCULAR_SUPERNESS = 0.551784777779014
+
+
+def circle(pen, origin, radius):
+    w = (origin[0] - radius, origin[1])
+    n = (origin[0], origin[1] + radius)
+    e = (origin[0] + radius, origin[1])
+    s = (origin[0], origin[1] - radius)
+
+    pen.moveTo(w)
+    pen.curveTo(
+        (w[0], w[1] + radius * CIRCULAR_SUPERNESS),
+        (n[0] - radius * CIRCULAR_SUPERNESS, n[1]),
+        n,
+    )
+    pen.curveTo(
+        (n[0] + radius * CIRCULAR_SUPERNESS, n[1]),
+        (e[0], e[1] + radius * CIRCULAR_SUPERNESS),
+        e,
+    )
+    pen.curveTo(
+        (e[0], e[1] - radius * CIRCULAR_SUPERNESS),
+        (s[0] + radius * CIRCULAR_SUPERNESS, s[1]),
+        s,
+    )
+    pen.curveTo(
+        (s[0] - radius * CIRCULAR_SUPERNESS, s[1]),
+        (w[0], w[1] - radius * CIRCULAR_SUPERNESS),
+        w,
+    )
+    pen.closePath()
+
+
+class DottedCircleFilter(BaseFilter):
+
+    _kwargs = {"margin": 80, "sidebearing": 160, "dots": 12}
+
+    def __call__(self, font, glyphSet=None):
+        fontName = _LazyFontName(font)
+        if glyphSet is not None and getattr(glyphSet, "name", None):
+            logger.info("Running %s on %s-%s", self.name, fontName, glyphSet.name)
+        else:
+            logger.info("Running %s on %s", self.name, fontName)
+
+        if glyphSet is None:
+            glyphSet = _GlyphSet.from_layer(font)
+
+        self.set_context(font, glyphSet)
+        added_glyph = False
+        dotted_circle_glyph = self.check_dotted_circle()
+
+        if dotted_circle_glyph == DO_NOTHING:
+            return []
+
+        if not dotted_circle_glyph:
+            dotted_circle_glyph = self.draw_dotted_circle(glyphSet)
+            added_glyph = True
+
+        added_anchors = self.check_and_add_anchors(dotted_circle_glyph)
+
+        if added_anchors:
+            self.ensure_base(dotted_circle_glyph)
+
+        if added_glyph or added_anchors:
+            return [dotted_circle_glyph.name]
+        else:
+            return []
+
+    def check_dotted_circle(self):
+        """Check for the presence of a dotted circle glyph and return it"""
+        font = self.context.font
+        glyphset = self.context.glyphSet
+        dotted_circle = next((g.name for g in font if 0x25CC in g.unicodes), None)
+        if dotted_circle:
+            if dotted_circle not in glyphset:
+                logger.debug(
+                    "Found dotted circle glyph %s in font but not in glyphset",
+                    dotted_circle,
+                )
+                return DO_NOTHING
+            logger.debug("Found dotted circle glyph %s", dotted_circle)
+            return glyphset[dotted_circle]
+
+    def draw_dotted_circle(self, glyphSet):
+        """Add a new dotted circle glyph, drawing its outlines"""
+        font = self.context.font
+        logger.debug("Adding dotted circle glyph")
+        glyph = Glyph(name="uni25CC", unicodes=[0x25CC])
+        pen = glyph.getPen()
+
+        bigradius = (font.info.xHeight - 2 * self.options.margin) / 2
+        littleradius = bigradius / 6
+        left = self.options.sidebearing + littleradius
+        right = self.options.sidebearing + bigradius * 2 - littleradius
+        middleY = font.info.xHeight / 2
+        middleX = (left + right) / 2
+        subangle = 2 * math.pi / self.options.dots
+        for t in range(self.options.dots):
+            angle = t * subangle
+            cx = middleX + bigradius * math.cos(angle)
+            cy = middleY + bigradius * math.sin(angle)
+            circle(pen, (cx, cy), littleradius)
+
+        glyph.setRightMargin(self.options.sidebearing)
+
+        glyphSet["uni25CC"] = glyph
+        return glyph
+
+    def check_and_add_anchors(self, dotted_circle_glyph):
+        """Check that all mark-attached anchors are present on the dotted
+        circle glyph, synthesizing a position for any missing anchors."""
+        font = self.context.font
+
+        # First we will gather information about all the anchors in the
+        # font at present; for the anchors on marks (starting with "_")
+        # we just want to know their names, so we can match them with
+        # bases later. For the anchors on bases, we also want to store
+        # the position of the anchor so we can average them.
+        all_anchors = {}
+        any_added = False
+        anchorclass = None
+        for glyph in font:
+            width = None
+            try:
+                bounds = glyph.getBounds(font)
+                if bounds:
+                    width = bounds.xMax - bounds.xMin
+            except AttributeError:
+                bounds = glyph.bounds
+                if bounds:
+                    width = bounds[2] - bounds[0]
+            if width is None:
+                width = glyph.width
+            for anchor in glyph.anchors:
+                anchorclass = anchor.__class__
+                if anchor.name.startswith("_"):
+                    all_anchors[anchor.name] = []
+                    continue
+                if not width:
+                    continue
+                x_percentage = anchor.x / width
+                all_anchors.setdefault(anchor.name, []).append((x_percentage, anchor.y))
+
+        # Now we move to the dotted circle. What anchors do we have already?
+        dsanchors = set([a.name for a in dotted_circle_glyph.anchors])
+        for anchor, positions in all_anchors.items():
+            # Skip existing anchors on the dotted-circle, and any anchors
+            # which don't have a matching mark glyph (mark-to-lig etc.).
+            if anchor in dsanchors or f"_{anchor}" not in all_anchors:
+                continue
+
+            # And now we're creating a new one
+            anchor_x = dotted_circle_glyph.width * mean([v[0] for v in positions])
+            anchor_y = mean([v[1] for v in positions])
+            logger.debug(
+                "Adding anchor %s to dotted circle glyph at %i,%i",
+                anchor,
+                anchor_x,
+                anchor_y,
+            )
+            try:
+                newanchor = anchorclass()
+                newanchor.x = otRound(anchor_x)
+                newanchor.y = otRound(anchor_y)
+            except TypeError:
+                newanchor = anchorclass(otRound(anchor_x), otRound(anchor_y))
+            newanchor.name = anchor
+            dotted_circle_glyph.appendAnchor(newanchor)
+            any_added = True
+        return any_added
+
+    # We have added some anchors to the dotted circle glyph. Now we need to
+    # ensure the glyph is a base (and specifically a base glyph, not just
+    # unclassified), or else it won't be in the list of base glyphs when
+    # we come to the mark features writer, and all our work will be for nothing.
+    # Also note that if we had a dotted circle glyph in the font already and
+    # we have come from Glyphs, glyphsLib would only consider the glyph to
+    # be a base if it has anchors, and it might not have had any when glyphsLib
+    # wrote the GDEF table.
+    # So we have to go digging around for a GDEF table and modify it.
+    def ensure_base(self, dotted_circle_glyph):
+        dotted_circle = dotted_circle_glyph.name
+        font = self.context.font
+        feaFile = parseLayoutFeatures(font)
+        if ast.findTable(feaFile, "GDEF") is None:
+            # We have no GDEF table. GDEFFeatureWriter will create one
+            # using the font's lib.
+            font.lib.setdefault(OPENTYPE_CATEGORIES_KEY, {})[dotted_circle] = "base"
+            return
+        # We have GDEF table, so we need to find the GlyphClassDef, and add
+        # ourselves to the baseGlyphs set.
+        for st in feaFile.statements:
+            if isinstance(st, ast.TableBlock) and st.name == "GDEF":
+                for st2 in st.statements:
+                    if isinstance(st2, ast.GlyphClassDefStatement):
+                        if (
+                            st2.baseGlyphs
+                            and dotted_circle not in st2.baseGlyphs.glyphSet()
+                        ):
+                            st2.baseGlyphs.glyphs.append(dotted_circle)
+        # And then put the modified feature file back into the font
+        font.features.text = feaFile.asFea()
diff --git a/Lib/ufo2ft/postProcessor.py b/Lib/ufo2ft/postProcessor.py
index 2f8c1a7..ea0c39c 100644
--- a/Lib/ufo2ft/postProcessor.py
+++ b/Lib/ufo2ft/postProcessor.py
@@ -168,8 +168,10 @@ class PostProcessor:
     def _rename_glyphs_from_ufo(self):
         """Rename glyphs using ufo.lib.public.postscriptNames in UFO."""
         rename_map = self._build_production_names()
+        self.rename_glyphs(self.otf, rename_map)
 
-        otf = self.otf
+    @staticmethod
+    def rename_glyphs(otf, rename_map):
         otf.setGlyphOrder([rename_map.get(n, n) for n in otf.getGlyphOrder()])
 
         # we need to compile format 2 'post' table so that the 'extraNames'
diff --git a/Lib/ufo2ft/preProcessor.py b/Lib/ufo2ft/preProcessor.py
index 4780361..c0d476e 100644
--- a/Lib/ufo2ft/preProcessor.py
+++ b/Lib/ufo2ft/preProcessor.py
@@ -1,14 +1,42 @@
+import itertools
+
 from ufo2ft.constants import (
     COLOR_LAYER_MAPPING_KEY,
     COLOR_LAYERS_KEY,
     COLOR_PALETTES_KEY,
 )
-from ufo2ft.filters import loadFilters
+from ufo2ft.filters import isValidFilter, loadFilters
 from ufo2ft.filters.decomposeComponents import DecomposeComponentsFilter
 from ufo2ft.fontInfoData import getAttrWithFallback
 from ufo2ft.util import _GlyphSet
 
 
+def _load_custom_filters(ufo, filters=None):
+    # Args:
+    #   ufo: Font
+    #   filters: Optional[List[Union[Filter, EllipsisType]]])
+    # Returns: List[Filter]
+
+    # by default, load the filters from the lib; ellipsis is used as a placeholder
+    # so one can optionally insert additional filters=[f1, ..., f2] either
+    # before or after these, or override them by omitting the ellipsis.
+    if filters is None:
+        filters = [...]
+    seen_ellipsis = False
+    result = []
+    for f in filters:
+        if f is ...:
+            if seen_ellipsis:
+                raise ValueError("ellipsis not allowed more than once")
+            result.extend(itertools.chain(*loadFilters(ufo)))
+            seen_ellipsis = True
+        else:
+            if not isValidFilter(type(f)):
+                raise TypeError(f"Invalid filter: {f!r}")
+            result.append(f)
+    return result
+
+
 class BasePreProcessor:
     """Base class for objects that performs pre-processing operations on
     the UFO glyphs, such as decomposing composites, removing overlaps, or
@@ -26,8 +54,17 @@ class BasePreProcessor:
     initialization of the default filters.
 
     Custom filters can be applied before or after the default filters.
-    These are specified in the UFO lib.plist under the private key
+    These can be specified in the UFO lib.plist under the private key
     "com.github.googlei18n.ufo2ft.filters".
+    Alternatively the optional ``filters`` parameter can be used. This is a
+    list of filter instances (subclasses of BaseFilter) that overrides
+    those defined in the UFO lib. The list can be empty, meaning no custom
+    filters are run. If ``filters`` contain the special value ``...`` (i.e.
+    the actual ``ellipsis`` singleton, not the str literal '...'), then all
+    the filters from the UFO lib are loaded in its place. This allows to
+    insert additional filters before or after those already defined in the
+    UFO lib, as opposed to discard/replace them which is the default behavior
+    when ``...`` is absent.
     """
 
     def __init__(
@@ -37,7 +74,7 @@ class BasePreProcessor:
         layerName=None,
         skipExportGlyphs=None,
         filters=None,
-        **kwargs
+        **kwargs,
     ):
         self.ufo = ufo
         self.inplace = inplace
@@ -46,11 +83,10 @@ class BasePreProcessor:
             ufo, layerName, copy=not inplace, skipExportGlyphs=skipExportGlyphs
         )
         self.defaultFilters = self.initDefaultFilters(**kwargs)
-        if filters is None:
-            self.preFilters, self.postFilters = loadFilters(ufo)
-        else:
-            self.preFilters = [f for f in filters if f.pre]
-            self.postFilters = [f for f in filters if not f.pre]
+
+        filters = _load_custom_filters(ufo, filters)
+        self.preFilters = [f for f in filters if f.pre]
+        self.postFilters = [f for f in filters if not f.pre]
 
     def initDefaultFilters(self, **kwargs):
         return []  # pragma: no cover
@@ -260,18 +296,9 @@ class TTFInterpolatablePreProcessor:
             self.defaultFilters.append([])
             _init_explode_color_layer_glyphs_filter(ufo, self.defaultFilters[-1])
 
-        self.preFilters, self.postFilters = [], []
-        if filters is None:
-            for ufo in ufos:
-                pre, post = loadFilters(ufo)
-                self.preFilters.append(pre)
-                self.postFilters.append(post)
-        else:
-            pre = [f for f in filters if f.pre]
-            post = [f for f in filters if not f.pre]
-            for _ in ufos:
-                self.preFilters.append(pre)
-                self.postFilters.append(post)
+        filterses = [_load_custom_filters(ufo, filters) for ufo in ufos]
+        self.preFilters = [[f for f in filters if f.pre] for filters in filterses]
+        self.postFilters = [[f for f in filters if not f.pre] for filters in filterses]
 
     def process(self):
         from cu2qu.ufo import fonts_to_quadratic
diff --git a/requirements.txt b/requirements.txt
index cffb47d..70a5851 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,4 @@
-fonttools[ufo,lxml]==4.28.5
+fonttools[lxml,ufo]==4.29.1
 defcon==0.10.0
 cu2qu==1.6.7.post1
 compreffor==0.5.1.post1
@@ -7,4 +7,4 @@ cffsubr==0.2.9.post1
 skia-pathops==0.7.2
 
 # alternative UFO implementation
-ufoLib2==0.13.0
+ufoLib2==0.13.1


Test patch:
diff --git a/tests/data/DottedCircleTest.ufo/fontinfo.plist b/tests/data/DottedCircleTest.ufo/fontinfo.plist
new file mode 100644
index 0000000..a62c59c
--- /dev/null
+++ b/tests/data/DottedCircleTest.ufo/fontinfo.plist
@@ -0,0 +1,44 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>ascender</key>
+	<integer>800</integer>
+	<key>capHeight</key>
+	<integer>700</integer>
+	<key>descender</key>
+	<integer>-200</integer>
+	<key>familyName</key>
+	<string>Dotted Circle Test</string>
+	<key>openTypeHeadCreated</key>
+	<string>2022/04/18 20:12:58</string>
+	<key>postscriptBlueValues</key>
+	<array>
+		<real>-16.0</real>
+		<real>0.0</real>
+		<real>500.0</real>
+		<real>516.0</real>
+		<real>700.0</real>
+		<real>716.0</real>
+		<real>800.0</real>
+		<real>816.0</real>
+	</array>
+	<key>postscriptFontName</key>
+	<string>DottedCircleTest-Regular</string>
+	<key>postscriptOtherBlues</key>
+	<array>
+		<real>-216.0</real>
+		<real>-200.0</real>
+	</array>
+	<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>500</integer>
+</dict>
+</plist>
diff --git a/tests/data/DottedCircleTest.ufo/glyphs/a.glif b/tests/data/DottedCircleTest.ufo/glyphs/a.glif
new file mode 100644
index 0000000..05d8918
--- /dev/null
+++ b/tests/data/DottedCircleTest.ufo/glyphs/a.glif
@@ -0,0 +1,60 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<glyph name="a" format="2">
+	<advance width="600"/>
+	<unicode hex="0061"/>
+	<anchor x="346" y="546" name="top"/>
+	<anchor x="289" y="-11" name="bottom"/>
+	<outline>
+		<contour>
+			<point x="413" y="74" type="curve"/>
+			<point x="413" y="8"/>
+			<point x="432" y="-14"/>
+			<point x="465" y="-14" type="curve" smooth="yes"/>
+			<point x="477" y="-14"/>
+			<point x="503" y="-10"/>
+			<point x="503" y="33" type="curve" smooth="yes"/>
+			<point x="503" y="369" type="line"/>
+			<point x="503" y="500"/>
+			<point x="397" y="529"/>
+			<point x="320" y="529" type="curve" smooth="yes"/>
+			<point x="230" y="529"/>
+			<point x="121" y="487"/>
+			<point x="121" y="423" type="curve" smooth="yes"/>
+			<point x="121" y="389"/>
+			<point x="152" y="384"/>
+			<point x="174" y="388" type="curve" smooth="yes"/>
+			<point x="230" y="399"/>
+			<point x="200" y="472"/>
+			<point x="293" y="472" type="curve" smooth="yes"/>
+			<point x="412" y="472"/>
+			<point x="410" y="374"/>
+			<point x="410" y="343" type="curve"/>
+			<point x="215" y="316"/>
+			<point x="80" y="257"/>
+			<point x="80" y="113" type="curve" smooth="yes"/>
+			<point x="80" y="28"/>
+			<point x="136" y="-11"/>
+			<point x="219" y="-11" type="curve" smooth="yes"/>
+			<point x="289" y="-11"/>
+			<point x="364" y="2"/>
+		</contour>
+		<contour>
+			<point x="402" y="105"/>
+			<point x="318" y="42"/>
+			<point x="236" y="49" type="curve" smooth="yes"/>
+			<point x="194" y="53"/>
+			<point x="166" y="66"/>
+			<point x="166" y="130" type="curve" smooth="yes"/>
+			<point x="166" y="225"/>
+			<point x="283" y="261"/>
+			<point x="407" y="290" type="curve"/>
+			<point x="406" y="121" type="line"/>
+		</contour>
+	</outline>
+	<lib>
+		<dict>
+			<key>com.schriftgestaltung.Glyphs.lastChange</key>
+			<string>2022-04-18 20:32:56 +0000</string>
+		</dict>
+	</lib>
+</glyph>
diff --git a/tests/data/DottedCircleTest.ufo/glyphs/acutecomb.glif b/tests/data/DottedCircleTest.ufo/glyphs/acutecomb.glif
new file mode 100644
index 0000000..5ff825c
--- /dev/null
+++ b/tests/data/DottedCircleTest.ufo/glyphs/acutecomb.glif
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<glyph name="acutecomb" format="2">
+	<advance width="0"/>
+	<unicode hex="0301"/>
+	<anchor x="0" y="503" name="_top"/>
+	<outline>
+		<contour>
+			<point x="-90" y="651" type="line"/>
+			<point x="-59" y="586" type="line"/>
+			<point x="95" y="645" type="line"/>
+			<point x="57" y="751" type="line"/>
+		</contour>
+	</outline>
+	<lib>
+		<dict>
+			<key>com.schriftgestaltung.Glyphs.lastChange</key>
+			<string>2022-04-18 20:15:50 +0000</string>
+		</dict>
+	</lib>
+</glyph>
diff --git a/tests/data/DottedCircleTest.ufo/glyphs/c.glif b/tests/data/DottedCircleTest.ufo/glyphs/c.glif
new file mode 100644
index 0000000..8fcdd0f
--- /dev/null
+++ b/tests/data/DottedCircleTest.ufo/glyphs/c.glif
@@ -0,0 +1,52 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<glyph name="c" format="2">
+	<advance width="487"/>
+	<unicode hex="0063"/>
+	<anchor x="273" y="-24" name="bottom"/>
+	<outline>
+		<contour>
+			<point x="51" y="233" type="curve" smooth="yes"/>
+			<point x="51" y="65"/>
+			<point x="119" y="-14"/>
+			<point x="263" y="-14" type="curve" smooth="yes"/>
+			<point x="333" y="-14"/>
+			<point x="396" y="11"/>
+			<point x="432" y="53" type="curve" smooth="yes"/>
+			<point x="447" y="71"/>
+			<point x="462" y="89"/>
+			<point x="462" y="104" type="curve" smooth="yes"/>
+			<point x="462" y="120"/>
+			<point x="441" y="116"/>
+			<point x="441" y="116" type="curve" smooth="yes"/>
+			<point x="414" y="110"/>
+			<point x="362" y="43"/>
+			<point x="278" y="43" type="curve" smooth="yes"/>
+			<point x="183" y="43"/>
+			<point x="148" y="118"/>
+			<point x="148" y="269" type="curve" smooth="yes"/>
+			<point x="148" y="410"/>
+			<point x="192" y="472"/>
+			<point x="290" y="472" type="curve" smooth="yes"/>
+			<point x="331" y="472"/>
+			<point x="351" y="460"/>
+			<point x="368" y="425" type="curve" smooth="yes"/>
+			<point x="382" y="397"/>
+			<point x="397" y="387"/>
+			<point x="422" y="387" type="curve" smooth="yes"/>
+			<point x="455" y="387"/>
+			<point x="462" y="393"/>
+			<point x="462" y="423" type="curve" smooth="yes"/>
+			<point x="462" y="487"/>
+			<point x="394" y="529"/>
+			<point x="290" y="529" type="curve" smooth="yes"/>
+			<point x="137" y="529"/>
+			<point x="51" y="422"/>
+		</contour>
+	</outline>
+	<lib>
+		<dict>
+			<key>com.schriftgestaltung.Glyphs.lastChange</key>
+			<string>2022-04-18 20:34:17 +0000</string>
+		</dict>
+	</lib>
+</glyph>
diff --git a/tests/data/DottedCircleTest.ufo/glyphs/contents.plist b/tests/data/DottedCircleTest.ufo/glyphs/contents.plist
new file mode 100644
index 0000000..547edae
--- /dev/null
+++ b/tests/data/DottedCircleTest.ufo/glyphs/contents.plist
@@ -0,0 +1,14 @@
+<?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>a</key>
+	<string>a.glif</string>
+	<key>acutecomb</key>
+	<string>acutecomb.glif</string>
+	<key>c</key>
+	<string>c.glif</string>
+	<key>dotbelowcomb</key>
+	<string>dotbelowcomb.glif</string>
+</dict>
+</plist>
diff --git a/tests/data/DottedCircleTest.ufo/glyphs/dotbelowcomb.glif b/tests/data/DottedCircleTest.ufo/glyphs/dotbelowcomb.glif
new file mode 100644
index 0000000..8126abc
--- /dev/null
+++ b/tests/data/DottedCircleTest.ufo/glyphs/dotbelowcomb.glif
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<glyph name="dotbelowcomb" format="2">
+	<advance width="0"/>
+	<unicode hex="0323"/>
+	<anchor x="0" y="0" name="_bottom"/>
+	<outline>
+		<contour>
+			<point x="0" y="-175" type="curve" smooth="yes"/>
+			<point x="29" y="-175"/>
+			<point x="52" y="-152"/>
+			<point x="52" y="-123" type="curve" smooth="yes"/>
+			<point x="52" y="-94"/>
+			<point x="29" y="-71"/>
+			<point x="0" y="-71" type="curve" smooth="yes"/>
+			<point x="-29" y="-71"/>
+			<point x="-52" y="-94"/>
+			<point x="-52" y="-123" type="curve" smooth="yes"/>
+			<point x="-52" y="-152"/>
+			<point x="-29" y="-175"/>
+		</contour>
+	</outline>
+	<lib>
+		<dict>
+			<key>com.schriftgestaltung.Glyphs.lastChange</key>
+			<string>2022-04-18 20:32:37 +0000</string>
+		</dict>
+	</lib>
+</glyph>
diff --git a/tests/data/DottedCircleTest.ufo/layercontents.plist b/tests/data/DottedCircleTest.ufo/layercontents.plist
new file mode 100644
index 0000000..cf95d35
--- /dev/null
+++ b/tests/data/DottedCircleTest.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/DottedCircleTest.ufo/lib.plist b/tests/data/DottedCircleTest.ufo/lib.plist
new file mode 100644
index 0000000..71db409
--- /dev/null
+++ b/tests/data/DottedCircleTest.ufo/lib.plist
@@ -0,0 +1,25 @@
+<?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.schriftgestaltung.DisplayStrings</key>
+	<array>
+		<string>cacacaa</string>
+	</array>
+	<key>com.schriftgestaltung.disablesAutomaticAlignment</key>
+	<false/>
+	<key>com.schriftgestaltung.fontMasterID</key>
+	<string>m01</string>
+	<key>com.schriftgestaltung.glyphOrder</key>
+	<false/>
+	<key>com.schriftgestaltung.useNiceNames</key>
+	<true/>
+	<key>public.glyphOrder</key>
+	<array>
+		<string>a</string>
+		<string>c</string>
+		<string>acutecomb</string>
+		<string>dotbelowcomb</string>
+	</array>
+</dict>
+</plist>
diff --git a/tests/data/DottedCircleTest.ufo/metainfo.plist b/tests/data/DottedCircleTest.ufo/metainfo.plist
new file mode 100644
index 0000000..74e4b3b
--- /dev/null
+++ b/tests/data/DottedCircleTest.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.schriftgestaltung.GlyphsUFOExport</string>
+	<key>formatVersion</key>
+	<integer>3</integer>
+</dict>
+</plist>
diff --git a/tests/featureCompiler_test.py b/tests/featureCompiler_test.py
index f2d728f..56d88b8 100644
--- a/tests/featureCompiler_test.py
+++ b/tests/featureCompiler_test.py
@@ -78,6 +78,13 @@ class ParseLayoutFeaturesTest:
         assert "change the file name in the include" in caplog.text
 
 
+class DummyFeatureWriter:
+    tableTag = "GPOS"
+
+    def write(self, font, feaFile, compiler=None):
+        pass
+
+
 class FeatureCompilerTest:
     def test_ttFont(self, FontClass):
         ufo = FontClass()
@@ -174,6 +181,24 @@ class FeatureCompilerTest:
         assert isinstance(compiler.featureWriters[0], KernFeatureWriter)
         assert "GPOS" in ttFont
 
+    def test_loadFeatureWriters_from_both_UFO_lib_and_argument(self, FontClass):
+        ufo = FontClass()
+        ufo.lib[FEATURE_WRITERS_KEY] = [{"class": "KernFeatureWriter"}]
+        compiler = FeatureCompiler(ufo, featureWriters=[..., DummyFeatureWriter])
+
+        assert len(compiler.featureWriters) == 2
+        assert isinstance(compiler.featureWriters[0], KernFeatureWriter)
+        assert isinstance(compiler.featureWriters[1], DummyFeatureWriter)
+
+    def test_loadFeatureWriters_from_both_defaults_and_argument(self, FontClass):
+        ufo = FontClass()
+        compiler = FeatureCompiler(ufo, featureWriters=[DummyFeatureWriter, ...])
+
+        assert len(compiler.featureWriters) == 1 + len(
+            FeatureCompiler.defaultFeatureWriters
+        )
+        assert isinstance(compiler.featureWriters[0], DummyFeatureWriter)
+
     def test_GSUB_writers_run_first(self, FontClass):
         class FooFeatureWriter(BaseFeatureWriter):
 
diff --git a/tests/filters/dottedCircle_test.py b/tests/filters/dottedCircle_test.py
new file mode 100644
index 0000000..f4c8530
--- /dev/null
+++ b/tests/filters/dottedCircle_test.py
@@ -0,0 +1,23 @@
+from ufo2ft.filters.dottedCircleFilter import DottedCircleFilter
+from ufo2ft.util import _GlyphSet
+
+
+def test_dotted_circle_filter(FontClass, datadir):
+    ufo_path = datadir.join("DottedCircleTest.ufo")
+    font = FontClass(ufo_path)
+    assert "uni25CC" not in font
+    philter = DottedCircleFilter()
+    glyphset = _GlyphSet.from_layer(font)
+    modified = philter(font, glyphset)
+    assert "uni25CC" in modified
+    anchors = list(sorted(glyphset["uni25CC"].anchors, key=lambda x: x.name))
+    assert anchors[0].x == 464
+    assert anchors[0].y == -17
+    assert anchors[0].name == "bottom"
+
+    assert anchors[1].x == 563
+    assert anchors[1].y == 546
+    assert anchors[1].name == "top"
+
+    assert len(glyphset["uni25CC"]) == 12
+    assert int(glyphset["uni25CC"].width) == 688
diff --git a/tests/preProcessor_test.py b/tests/preProcessor_test.py
index 328b668..11196f8 100644
--- a/tests/preProcessor_test.py
+++ b/tests/preProcessor_test.py
@@ -116,6 +116,21 @@ class TTFPreProcessorTest:
         # filter2, before overlaps were removed in a post-filter filter1
         assert len(glyphSets0["d"].components) == 0
 
+    def test_custom_filters_in_both_lib_and_argument_with_ellipsis(self, FontClass):
+        from ufo2ft.filters import TransformationsFilter
+
+        ufo = FontClass(getpath("TestFont.ufo"))
+        ufo.lib[FILTERS_KEY] = [
+            {"name": "transformations", "kwargs": {"OffsetX": 10}, "pre": True}
+        ]
+
+        glyphSet = TTFPreProcessor(
+            ufo, filters=[..., TransformationsFilter(OffsetY=-10)]
+        ).process()
+
+        a = glyphSet["a"]
+        assert (a[0][0].x, a[0][0].y) == (ufo["a"][0][0].x + 10, ufo["a"][0][0].y - 10)
+
 
 class TTFInterpolatablePreProcessorTest:
     def test_no_inplace(self, FontClass):
@@ -205,6 +220,34 @@ class TTFInterpolatablePreProcessorTest:
         # filter2, before overlaps were removed in a post-filter filter1
         assert len(glyphSets[0]["d"].components) == 0
 
+    def test_custom_filters_in_both_lib_and_argument_with_ellipsis(self, FontClass):
+        from ufo2ft.filters import TransformationsFilter
+
+        ufo1 = FontClass(getpath("TestFont.ufo"))
+        ufo1.lib[FILTERS_KEY] = [
+            {"name": "transformations", "kwargs": {"OffsetX": 10}, "pre": True}
+        ]
+
+        ufo2 = FontClass(getpath("TestFont.ufo"))
+        ufo2.lib[FILTERS_KEY] = [
+            {"name": "transformations", "kwargs": {"OffsetX": 20}, "pre": True}
+        ]
+
+        glyphSets = TTFInterpolatablePreProcessor(
+            [ufo1, ufo2], filters=[..., TransformationsFilter(OffsetY=-10)]
+        ).process()
+
+        a1 = glyphSets[0]["a"]
+        assert (a1[0][0].x, a1[0][0].y) == (
+            ufo1["a"][0][0].x + 10,
+            ufo1["a"][0][0].y - 10,
+        )
+        a2 = glyphSets[1]["a"]
+        assert (a2[0][0].x, a2[0][0].y) == (
+            ufo2["a"][0][0].x + 20,
+            ufo2["a"][0][0].y - 10,
+        )
+
 
 class SkipExportGlyphsTest:
     def test_skip_export_glyphs_filter(self, FontClass):