Merge pull request #3460 from fonttools/decompose-filter-pen

add filter pens that decompose components
This commit is contained in:
Cosimo Lupo 2024-03-05 16:14:10 +00:00 committed by GitHub
commit a3b9eddcaf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 516 additions and 33 deletions

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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.

View File

@ -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