diff --git a/Lib/fontTools/pens/basePen.py b/Lib/fontTools/pens/basePen.py index 5d2cf5032..ba38f7009 100644 --- a/Lib/fontTools/pens/basePen.py +++ b/Lib/fontTools/pens/basePen.py @@ -39,7 +39,7 @@ sequence of length 2 will do. from typing import Tuple, Dict from fontTools.misc.loggingTools import LogMixin -from fontTools.misc.transform import DecomposedTransform +from fontTools.misc.transform import DecomposedTransform, Identity __all__ = [ "AbstractPen", @@ -195,17 +195,40 @@ class DecomposingPen(LoggingPen): By default a warning message is logged when a base glyph is missing; set the class variable ``skipMissingComponents`` to False if you want - to raise a :class:`MissingComponentError` exception. + all instances of a sub-class to raise a :class:`MissingComponentError` + exception by default. """ skipMissingComponents = True + # alias error for convenience + MissingComponentError = MissingComponentError - def __init__(self, glyphSet): - """Takes a single 'glyphSet' argument (dict), in which the glyphs - that are referenced as components are looked up by their name. + def __init__( + self, + glyphSet, + *args, + skipMissingComponents=None, + reverseFlipped=False, + **kwargs, + ): + """Takes a 'glyphSet' argument (dict), in which the glyphs that are referenced + as components are looked up by their name. + + If the optional 'reverseFlipped' argument is True, components whose transformation + matrix has a negative determinant will be decomposed with a reversed path direction + to compensate for the flip. + + The optional 'skipMissingComponents' argument can be set to True/False to + override the homonymous class attribute for a given pen instance. """ - super(DecomposingPen, self).__init__() + super(DecomposingPen, self).__init__(*args, **kwargs) self.glyphSet = glyphSet + self.skipMissingComponents = ( + self.__class__.skipMissingComponents + if skipMissingComponents is None + else skipMissingComponents + ) + self.reverseFlipped = reverseFlipped def addComponent(self, glyphName, transformation): """Transform the points of the base glyph and draw it onto self.""" @@ -218,8 +241,19 @@ class DecomposingPen(LoggingPen): raise MissingComponentError(glyphName) self.log.warning("glyph '%s' is missing from glyphSet; skipped" % glyphName) else: - tPen = TransformPen(self, transformation) - glyph.draw(tPen) + pen = self + if transformation != Identity: + pen = TransformPen(pen, transformation) + if self.reverseFlipped: + # if the transformation has a negative determinant, it will + # reverse the contour direction of the component + a, b, c, d = transformation[:4] + det = a * d - b * c + if det < 0: + from fontTools.pens.reverseContourPen import ReverseContourPen + + pen = ReverseContourPen(pen) + glyph.draw(pen) def addVarComponent(self, glyphName, transformation, location): # GlyphSet decomposes for us diff --git a/Lib/fontTools/pens/filterPen.py b/Lib/fontTools/pens/filterPen.py index 6c8712c26..f104e67dd 100644 --- a/Lib/fontTools/pens/filterPen.py +++ b/Lib/fontTools/pens/filterPen.py @@ -1,5 +1,7 @@ -from fontTools.pens.basePen import AbstractPen -from fontTools.pens.pointPen import AbstractPointPen +from __future__ import annotations + +from fontTools.pens.basePen import AbstractPen, DecomposingPen +from fontTools.pens.pointPen import AbstractPointPen, DecomposingPointPen from fontTools.pens.recordingPen import RecordingPen @@ -150,8 +152,8 @@ class FilterPointPen(_PassThruComponentsMixin, AbstractPointPen): ('endPath', (), {}) """ - def __init__(self, outPointPen): - self._outPen = outPointPen + def __init__(self, outPen): + self._outPen = outPen def beginPath(self, **kwargs): self._outPen.beginPath(**kwargs) @@ -161,3 +163,79 @@ class FilterPointPen(_PassThruComponentsMixin, AbstractPointPen): def addPoint(self, pt, segmentType=None, smooth=False, name=None, **kwargs): self._outPen.addPoint(pt, segmentType, smooth, name, **kwargs) + + +class _DecomposingFilterPenMixin: + """Mixin class that decomposes components as regular contours. + + Shared by both DecomposingFilterPen and DecomposingFilterPointPen. + + Takes two required parameters, another (segment or point) pen 'outPen' to draw + with, and a 'glyphSet' dict of drawable glyph objects to draw components from. + + The 'skipMissingComponents' and 'reverseFlipped' optional arguments work the + same as in the DecomposingPen/DecomposingPointPen. Both are False by default. + + In addition, the decomposing filter pens also take the following two options: + + 'include' is an optional set of component base glyph names to consider for + decomposition; the default include=None means decompose all components no matter + the base glyph name). + + 'decomposeNested' (bool) controls whether to recurse decomposition into nested + components of components (this only matters when 'include' was also provided); + if False, only decompose top-level components included in the set, but not + also their children. + """ + + # raises MissingComponentError if base glyph is not found in glyphSet + skipMissingComponents = False + + def __init__( + self, + outPen, + glyphSet, + skipMissingComponents=None, + reverseFlipped=False, + include: set[str] | None = None, + decomposeNested: bool = True, + ): + super().__init__( + outPen=outPen, + glyphSet=glyphSet, + skipMissingComponents=skipMissingComponents, + reverseFlipped=reverseFlipped, + ) + self.include = include + self.decomposeNested = decomposeNested + + def addComponent(self, baseGlyphName, transformation, **kwargs): + # only decompose the component if it's included in the set + if self.include is None or baseGlyphName in self.include: + # if we're decomposing nested components, temporarily set include to None + include_bak = self.include + if self.decomposeNested and self.include: + self.include = None + try: + super().addComponent(baseGlyphName, transformation, **kwargs) + finally: + if self.include != include_bak: + self.include = include_bak + else: + _PassThruComponentsMixin.addComponent( + self, baseGlyphName, transformation, **kwargs + ) + + +class DecomposingFilterPen(_DecomposingFilterPenMixin, DecomposingPen, FilterPen): + """Filter pen that draws components as regular contours.""" + + pass + + +class DecomposingFilterPointPen( + _DecomposingFilterPenMixin, DecomposingPointPen, FilterPointPen +): + """Filter point pen that draws components as regular contours.""" + + pass diff --git a/Lib/fontTools/pens/pointPen.py b/Lib/fontTools/pens/pointPen.py index eb1ebc204..93a9201c9 100644 --- a/Lib/fontTools/pens/pointPen.py +++ b/Lib/fontTools/pens/pointPen.py @@ -15,8 +15,9 @@ For instance, whether or not a point is smooth, and its name. import math from typing import Any, Optional, Tuple, Dict -from fontTools.pens.basePen import AbstractPen, PenError -from fontTools.misc.transform import DecomposedTransform +from fontTools.misc.loggingTools import LogMixin +from fontTools.pens.basePen import AbstractPen, MissingComponentError, PenError +from fontTools.misc.transform import DecomposedTransform, Identity __all__ = [ "AbstractPointPen", @@ -523,3 +524,77 @@ class ReverseContourPointPen(AbstractPointPen): if self.currentContour is not None: raise PenError("Components must be added before or after contours") self.pen.addComponent(glyphName, transform, identifier=identifier, **kwargs) + + +class DecomposingPointPen(LogMixin, AbstractPointPen): + """Implements a 'addComponent' method that decomposes components + (i.e. draws them onto self as simple contours). + It can also be used as a mixin class (e.g. see DecomposingRecordingPointPen). + + You must override beginPath, addPoint, endPath. You may + additionally override addVarComponent and addComponent. + + By default a warning message is logged when a base glyph is missing; + set the class variable ``skipMissingComponents`` to False if you want + all instances of a sub-class to raise a :class:`MissingComponentError` + exception by default. + """ + + skipMissingComponents = True + # alias error for convenience + MissingComponentError = MissingComponentError + + def __init__( + self, + glyphSet, + *args, + skipMissingComponents=None, + reverseFlipped=False, + **kwargs, + ): + """Takes a 'glyphSet' argument (dict), in which the glyphs that are referenced + as components are looked up by their name. + + If the optional 'reverseFlipped' argument is True, components whose transformation + matrix has a negative determinant will be decomposed with a reversed path direction + to compensate for the flip. + + The optional 'skipMissingComponents' argument can be set to True/False to + override the homonymous class attribute for a given pen instance. + """ + super().__init__(*args, **kwargs) + self.glyphSet = glyphSet + self.skipMissingComponents = ( + self.__class__.skipMissingComponents + if skipMissingComponents is None + else skipMissingComponents + ) + self.reverseFlipped = reverseFlipped + + def addComponent(self, baseGlyphName, transformation, identifier=None, **kwargs): + """Transform the points of the base glyph and draw it onto self. + + The `identifier` parameter and any extra kwargs are ignored. + """ + from fontTools.pens.transformPen import TransformPointPen + + try: + glyph = self.glyphSet[baseGlyphName] + except KeyError: + if not self.skipMissingComponents: + raise MissingComponentError(baseGlyphName) + self.log.warning( + "glyph '%s' is missing from glyphSet; skipped" % baseGlyphName + ) + else: + pen = self + if transformation != Identity: + pen = TransformPointPen(pen, transformation) + if self.reverseFlipped: + # if the transformation has a negative determinant, it will + # reverse the contour direction of the component + a, b, c, d = transformation[:4] + det = a * d - b * c + if a * d - b * c < 0: + pen = ReverseContourPointPen(pen) + glyph.drawPoints(pen) diff --git a/Lib/fontTools/pens/recordingPen.py b/Lib/fontTools/pens/recordingPen.py index 4f44a4d59..687673d70 100644 --- a/Lib/fontTools/pens/recordingPen.py +++ b/Lib/fontTools/pens/recordingPen.py @@ -1,7 +1,7 @@ """Pen recording operations that can be accessed or replayed.""" from fontTools.pens.basePen import AbstractPen, DecomposingPen -from fontTools.pens.pointPen import AbstractPointPen +from fontTools.pens.pointPen import AbstractPointPen, DecomposingPointPen __all__ = [ @@ -85,28 +85,55 @@ class DecomposingRecordingPen(DecomposingPen, RecordingPen): """Same as RecordingPen, except that it doesn't keep components as references, but draws them decomposed as regular contours. - The constructor takes a single 'glyphSet' positional argument, + The constructor takes a required 'glyphSet' positional argument, a dictionary of glyph objects (i.e. with a 'draw' method) keyed - by thir name:: + by thir name; other arguments are forwarded to the DecomposingPen's + constructor:: - >>> class SimpleGlyph(object): - ... def draw(self, pen): - ... pen.moveTo((0, 0)) - ... pen.curveTo((1, 1), (2, 2), (3, 3)) - ... pen.closePath() - >>> class CompositeGlyph(object): - ... def draw(self, pen): - ... pen.addComponent('a', (1, 0, 0, 1, -1, 1)) - >>> glyphSet = {'a': SimpleGlyph(), 'b': CompositeGlyph()} - >>> for name, glyph in sorted(glyphSet.items()): - ... pen = DecomposingRecordingPen(glyphSet) - ... glyph.draw(pen) - ... print("{}: {}".format(name, pen.value)) - a: [('moveTo', ((0, 0),)), ('curveTo', ((1, 1), (2, 2), (3, 3))), ('closePath', ())] - b: [('moveTo', ((-1, 1),)), ('curveTo', ((0, 2), (1, 3), (2, 4))), ('closePath', ())] + >>> class SimpleGlyph(object): + ... def draw(self, pen): + ... pen.moveTo((0, 0)) + ... pen.curveTo((1, 1), (2, 2), (3, 3)) + ... pen.closePath() + >>> class CompositeGlyph(object): + ... def draw(self, pen): + ... pen.addComponent('a', (1, 0, 0, 1, -1, 1)) + >>> class MissingComponent(object): + ... def draw(self, pen): + ... pen.addComponent('foobar', (1, 0, 0, 1, 0, 0)) + >>> class FlippedComponent(object): + ... def draw(self, pen): + ... pen.addComponent('a', (-1, 0, 0, 1, 0, 0)) + >>> glyphSet = { + ... 'a': SimpleGlyph(), + ... 'b': CompositeGlyph(), + ... 'c': MissingComponent(), + ... 'd': FlippedComponent(), + ... } + >>> for name, glyph in sorted(glyphSet.items()): + ... pen = DecomposingRecordingPen(glyphSet) + ... try: + ... glyph.draw(pen) + ... except pen.MissingComponentError: + ... pass + ... print("{}: {}".format(name, pen.value)) + a: [('moveTo', ((0, 0),)), ('curveTo', ((1, 1), (2, 2), (3, 3))), ('closePath', ())] + b: [('moveTo', ((-1, 1),)), ('curveTo', ((0, 2), (1, 3), (2, 4))), ('closePath', ())] + c: [] + d: [('moveTo', ((0, 0),)), ('curveTo', ((-1, 1), (-2, 2), (-3, 3))), ('closePath', ())] + >>> for name, glyph in sorted(glyphSet.items()): + ... pen = DecomposingRecordingPen( + ... glyphSet, skipMissingComponents=True, reverseFlipped=True, + ... ) + ... glyph.draw(pen) + ... print("{}: {}".format(name, pen.value)) + a: [('moveTo', ((0, 0),)), ('curveTo', ((1, 1), (2, 2), (3, 3))), ('closePath', ())] + b: [('moveTo', ((-1, 1),)), ('curveTo', ((0, 2), (1, 3), (2, 4))), ('closePath', ())] + c: [] + d: [('moveTo', ((0, 0),)), ('lineTo', ((-3, 3),)), ('curveTo', ((-2, 2), (-1, 1), (0, 0))), ('closePath', ())] """ - # raises KeyError if base glyph is not found in glyphSet + # raises MissingComponentError(KeyError) if base glyph is not found in glyphSet skipMissingComponents = False @@ -174,6 +201,96 @@ class RecordingPointPen(AbstractPointPen): drawPoints = replay +class DecomposingRecordingPointPen(DecomposingPointPen, RecordingPointPen): + """Same as RecordingPointPen, except that it doesn't keep components + as references, but draws them decomposed as regular contours. + + The constructor takes a required 'glyphSet' positional argument, + a dictionary of pointPen-drawable glyph objects (i.e. with a 'drawPoints' method) + keyed by thir name; other arguments are forwarded to the DecomposingPointPen's + constructor:: + + >>> from pprint import pprint + >>> class SimpleGlyph(object): + ... def drawPoints(self, pen): + ... pen.beginPath() + ... pen.addPoint((0, 0), "line") + ... pen.addPoint((1, 1)) + ... pen.addPoint((2, 2)) + ... pen.addPoint((3, 3), "curve") + ... pen.endPath() + >>> class CompositeGlyph(object): + ... def drawPoints(self, pen): + ... pen.addComponent('a', (1, 0, 0, 1, -1, 1)) + >>> class MissingComponent(object): + ... def drawPoints(self, pen): + ... pen.addComponent('foobar', (1, 0, 0, 1, 0, 0)) + >>> class FlippedComponent(object): + ... def drawPoints(self, pen): + ... pen.addComponent('a', (-1, 0, 0, 1, 0, 0)) + >>> glyphSet = { + ... 'a': SimpleGlyph(), + ... 'b': CompositeGlyph(), + ... 'c': MissingComponent(), + ... 'd': FlippedComponent(), + ... } + >>> for name, glyph in sorted(glyphSet.items()): + ... pen = DecomposingRecordingPointPen(glyphSet) + ... try: + ... glyph.drawPoints(pen) + ... except pen.MissingComponentError: + ... pass + ... pprint({name: pen.value}) + {'a': [('beginPath', (), {}), + ('addPoint', ((0, 0), 'line', False, None), {}), + ('addPoint', ((1, 1), None, False, None), {}), + ('addPoint', ((2, 2), None, False, None), {}), + ('addPoint', ((3, 3), 'curve', False, None), {}), + ('endPath', (), {})]} + {'b': [('beginPath', (), {}), + ('addPoint', ((-1, 1), 'line', False, None), {}), + ('addPoint', ((0, 2), None, False, None), {}), + ('addPoint', ((1, 3), None, False, None), {}), + ('addPoint', ((2, 4), 'curve', False, None), {}), + ('endPath', (), {})]} + {'c': []} + {'d': [('beginPath', (), {}), + ('addPoint', ((0, 0), 'line', False, None), {}), + ('addPoint', ((-1, 1), None, False, None), {}), + ('addPoint', ((-2, 2), None, False, None), {}), + ('addPoint', ((-3, 3), 'curve', False, None), {}), + ('endPath', (), {})]} + >>> for name, glyph in sorted(glyphSet.items()): + ... pen = DecomposingRecordingPointPen( + ... glyphSet, skipMissingComponents=True, reverseFlipped=True, + ... ) + ... glyph.drawPoints(pen) + ... pprint({name: pen.value}) + {'a': [('beginPath', (), {}), + ('addPoint', ((0, 0), 'line', False, None), {}), + ('addPoint', ((1, 1), None, False, None), {}), + ('addPoint', ((2, 2), None, False, None), {}), + ('addPoint', ((3, 3), 'curve', False, None), {}), + ('endPath', (), {})]} + {'b': [('beginPath', (), {}), + ('addPoint', ((-1, 1), 'line', False, None), {}), + ('addPoint', ((0, 2), None, False, None), {}), + ('addPoint', ((1, 3), None, False, None), {}), + ('addPoint', ((2, 4), 'curve', False, None), {}), + ('endPath', (), {})]} + {'c': []} + {'d': [('beginPath', (), {}), + ('addPoint', ((0, 0), 'curve', False, None), {}), + ('addPoint', ((-3, 3), 'line', False, None), {}), + ('addPoint', ((-2, 2), None, False, None), {}), + ('addPoint', ((-1, 1), None, False, None), {}), + ('endPath', (), {})]} + """ + + # raises MissingComponentError(KeyError) if base glyph is not found in glyphSet + skipMissingComponents = False + + def lerpRecordings(recording1, recording2, factor=0.5): """Linearly interpolate between two recordings. The recordings must be decomposed, i.e. they must not contain any components. diff --git a/Tests/pens/filterPen_test.py b/Tests/pens/filterPen_test.py new file mode 100644 index 000000000..569d3ee6a --- /dev/null +++ b/Tests/pens/filterPen_test.py @@ -0,0 +1,179 @@ +import logging +from types import MappingProxyType + +from fontTools.pens.basePen import MissingComponentError +from fontTools.pens.filterPen import DecomposingFilterPen, DecomposingFilterPointPen +from fontTools.pens.pointPen import PointToSegmentPen +from fontTools.pens.recordingPen import RecordingPen, RecordingPointPen + +import pytest + + +class SimpleGlyph: + def draw(self, pen): + pen.moveTo((0, 0)) + pen.curveTo((1, 1), (2, 2), (3, 3)) + pen.closePath() + + def drawPoints(self, pen): + pen.beginPath() + pen.addPoint((0, 0), "line") + pen.addPoint((1, 1)) + pen.addPoint((2, 2)) + pen.addPoint((3, 3), "curve") + pen.endPath() + + +class CompositeGlyph: + def draw(self, pen): + pen.addComponent("simple_glyph", (1, 0, 0, 1, -1, 1)) + + def drawPoints(self, pen): + self.draw(pen) + + +class MissingComponent(CompositeGlyph): + def draw(self, pen): + pen.addComponent("foobar", (1, 0, 0, 1, 0, 0)) + + +class FlippedComponent(CompositeGlyph): + def draw(self, pen): + pen.addComponent("simple_glyph", (1, 0, 0, 1, 0, 0)) + pen.addComponent("composite_glyph", (-1, 0, 0, 1, 0, 0)) + + +GLYPHSET = MappingProxyType( + { + "simple_glyph": SimpleGlyph(), + "composite_glyph": CompositeGlyph(), + "missing_component": MissingComponent(), + "flipped_component": FlippedComponent(), + } +) + + +def _is_point_pen(pen): + return hasattr(pen, "addPoint") + + +def _draw(glyph, pen): + if _is_point_pen(pen): + # point pen + glyph.drawPoints(pen) + else: + # segment pen + glyph.draw(pen) + + +@pytest.fixture(params=[DecomposingFilterPen, DecomposingFilterPointPen]) +def FilterPen(request): + return request.param + + +def _init_rec_and_filter_pens(FilterPenClass, *args, **kwargs): + rec = out = RecordingPen() + if _is_point_pen(FilterPenClass): + out = PointToSegmentPen(rec) + fpen = FilterPenClass(out, GLYPHSET, *args, **kwargs) + return rec, fpen + + +@pytest.mark.parametrize( + "glyph_name, expected", + [ + ( + "simple_glyph", + [ + ("moveTo", ((0, 0),)), + ("curveTo", ((1, 1), (2, 2), (3, 3))), + ("closePath", ()), + ], + ), + ( + "composite_glyph", + [ + ("moveTo", ((-1, 1),)), + ("curveTo", ((0, 2), (1, 3), (2, 4))), + ("closePath", ()), + ], + ), + ("missing_component", MissingComponentError), + ( + "flipped_component", + [ + ("moveTo", ((0, 0),)), + ("curveTo", ((1, 1), (2, 2), (3, 3))), + ("closePath", ()), + ("moveTo", ((1, 1),)), + ("curveTo", ((0, 2), (-1, 3), (-2, 4))), + ("closePath", ()), + ], + ), + ], +) +def test_decomposing_filter_pen(FilterPen, glyph_name, expected): + rec, fpen = _init_rec_and_filter_pens(FilterPen) + glyph = GLYPHSET[glyph_name] + try: + _draw(glyph, fpen) + except Exception as e: + assert isinstance(e, expected) + else: + assert rec.value == expected + + +def test_decomposing_filter_pen_skip_missing(FilterPen, caplog): + rec, fpen = _init_rec_and_filter_pens(FilterPen, skipMissingComponents=True) + glyph = GLYPHSET["missing_component"] + with caplog.at_level(logging.WARNING, logger="fontTools.pens.filterPen"): + _draw(glyph, fpen) + assert rec.value == [] + assert "glyph 'foobar' is missing from glyphSet; skipped" in caplog.text + + +def test_decomposing_filter_pen_reverse_flipped(FilterPen): + rec, fpen = _init_rec_and_filter_pens(FilterPen, reverseFlipped=True) + glyph = GLYPHSET["flipped_component"] + _draw(glyph, fpen) + assert rec.value == [ + ("moveTo", ((0, 0),)), + ("curveTo", ((1, 1), (2, 2), (3, 3))), + ("closePath", ()), + ("moveTo", ((1, 1),)), + ("lineTo", ((-2, 4),)), + ("curveTo", ((-1, 3), (0, 2), (1, 1))), + ("closePath", ()), + ] + + +@pytest.mark.parametrize( + "decomposeNested, expected", + [ + ( + True, + [ + ("addComponent", ("simple_glyph", (1, 0, 0, 1, 0, 0))), + ("moveTo", ((1, 1),)), + ("curveTo", ((0, 2), (-1, 3), (-2, 4))), + ("closePath", ()), + ], + ), + ( + False, + [ + ("addComponent", ("simple_glyph", (1, 0, 0, 1, 0, 0))), + ("addComponent", ("simple_glyph", (-1, 0, 0, 1, 1, 1))), + ], + ), + ], +) +def test_decomposing_filter_pen_include_decomposeNested( + FilterPen, decomposeNested, expected +): + rec, fpen = _init_rec_and_filter_pens( + FilterPen, include={"composite_glyph"}, decomposeNested=decomposeNested + ) + glyph = GLYPHSET["flipped_component"] + _draw(glyph, fpen) + assert rec.value == expected