diff --git a/Lib/ufo2ft/filters/__init__.py b/Lib/ufo2ft/filters/__init__.py
index 9b38c1dca..3e7a332e9 100644
--- a/Lib/ufo2ft/filters/__init__.py
+++ b/Lib/ufo2ft/filters/__init__.py
@@ -9,6 +9,7 @@
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 @@
"CubicToQuadraticFilter",
"DecomposeComponentsFilter",
"DecomposeTransformedComponentsFilter",
+ "DottedCircleFilter",
"ExplodeColorLayerGlyphsFilter",
"FlattenComponentsFilter",
"PropagateAnchorsFilter",
diff --git a/Lib/ufo2ft/filters/dottedCircleFilter.py b/Lib/ufo2ft/filters/dottedCircleFilter.py
new file mode 100644
index 000000000..98cbaef37
--- /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::
+
+ com.github.googlei18n.ufo2ft.filters
+
+
+ name
+ DottedCircleFilter
+ pre
+
+
+
+
+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/tests/data/DottedCircleTest.ufo/fontinfo.plist b/tests/data/DottedCircleTest.ufo/fontinfo.plist
new file mode 100644
index 000000000..a62c59cd9
--- /dev/null
+++ b/tests/data/DottedCircleTest.ufo/fontinfo.plist
@@ -0,0 +1,44 @@
+
+
+
+
+ ascender
+ 800
+ capHeight
+ 700
+ descender
+ -200
+ familyName
+ Dotted Circle Test
+ openTypeHeadCreated
+ 2022/04/18 20:12:58
+ postscriptBlueValues
+
+ -16.0
+ 0.0
+ 500.0
+ 516.0
+ 700.0
+ 716.0
+ 800.0
+ 816.0
+
+ postscriptFontName
+ DottedCircleTest-Regular
+ postscriptOtherBlues
+
+ -216.0
+ -200.0
+
+ styleName
+ Regular
+ unitsPerEm
+ 1000
+ versionMajor
+ 1
+ versionMinor
+ 0
+ xHeight
+ 500
+
+
diff --git a/tests/data/DottedCircleTest.ufo/glyphs/a.glif b/tests/data/DottedCircleTest.ufo/glyphs/a.glif
new file mode 100644
index 000000000..05d8918e4
--- /dev/null
+++ b/tests/data/DottedCircleTest.ufo/glyphs/a.glif
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ com.schriftgestaltung.Glyphs.lastChange
+ 2022-04-18 20:32:56 +0000
+
+
+
diff --git a/tests/data/DottedCircleTest.ufo/glyphs/acutecomb.glif b/tests/data/DottedCircleTest.ufo/glyphs/acutecomb.glif
new file mode 100644
index 000000000..5ff825ce4
--- /dev/null
+++ b/tests/data/DottedCircleTest.ufo/glyphs/acutecomb.glif
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ com.schriftgestaltung.Glyphs.lastChange
+ 2022-04-18 20:15:50 +0000
+
+
+
diff --git a/tests/data/DottedCircleTest.ufo/glyphs/c.glif b/tests/data/DottedCircleTest.ufo/glyphs/c.glif
new file mode 100644
index 000000000..8fcdd0ff4
--- /dev/null
+++ b/tests/data/DottedCircleTest.ufo/glyphs/c.glif
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ com.schriftgestaltung.Glyphs.lastChange
+ 2022-04-18 20:34:17 +0000
+
+
+
diff --git a/tests/data/DottedCircleTest.ufo/glyphs/contents.plist b/tests/data/DottedCircleTest.ufo/glyphs/contents.plist
new file mode 100644
index 000000000..547edae2e
--- /dev/null
+++ b/tests/data/DottedCircleTest.ufo/glyphs/contents.plist
@@ -0,0 +1,14 @@
+
+
+
+
+ a
+ a.glif
+ acutecomb
+ acutecomb.glif
+ c
+ c.glif
+ dotbelowcomb
+ dotbelowcomb.glif
+
+
diff --git a/tests/data/DottedCircleTest.ufo/glyphs/dotbelowcomb.glif b/tests/data/DottedCircleTest.ufo/glyphs/dotbelowcomb.glif
new file mode 100644
index 000000000..8126abc1b
--- /dev/null
+++ b/tests/data/DottedCircleTest.ufo/glyphs/dotbelowcomb.glif
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ com.schriftgestaltung.Glyphs.lastChange
+ 2022-04-18 20:32:37 +0000
+
+
+
diff --git a/tests/data/DottedCircleTest.ufo/layercontents.plist b/tests/data/DottedCircleTest.ufo/layercontents.plist
new file mode 100644
index 000000000..cf95d3573
--- /dev/null
+++ b/tests/data/DottedCircleTest.ufo/layercontents.plist
@@ -0,0 +1,10 @@
+
+
+
+
+
+ public.default
+ glyphs
+
+
+
diff --git a/tests/data/DottedCircleTest.ufo/lib.plist b/tests/data/DottedCircleTest.ufo/lib.plist
new file mode 100644
index 000000000..71db409e0
--- /dev/null
+++ b/tests/data/DottedCircleTest.ufo/lib.plist
@@ -0,0 +1,25 @@
+
+
+
+
+ com.schriftgestaltung.DisplayStrings
+
+ cacacaa
+
+ com.schriftgestaltung.disablesAutomaticAlignment
+
+ com.schriftgestaltung.fontMasterID
+ m01
+ com.schriftgestaltung.glyphOrder
+
+ com.schriftgestaltung.useNiceNames
+
+ public.glyphOrder
+
+ a
+ c
+ acutecomb
+ dotbelowcomb
+
+
+
diff --git a/tests/data/DottedCircleTest.ufo/metainfo.plist b/tests/data/DottedCircleTest.ufo/metainfo.plist
new file mode 100644
index 000000000..74e4b3b4f
--- /dev/null
+++ b/tests/data/DottedCircleTest.ufo/metainfo.plist
@@ -0,0 +1,10 @@
+
+
+
+
+ creator
+ com.schriftgestaltung.GlyphsUFOExport
+ formatVersion
+ 3
+
+
diff --git a/tests/filters/dottedCircle_test.py b/tests/filters/dottedCircle_test.py
new file mode 100644
index 000000000..f4c853020
--- /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