From 4f6c739181af2de9fbecc3d4a98c589ff1b0241c Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Wed, 6 Dec 2023 08:34:51 -0700 Subject: [PATCH] Move LerpGlyphSet and lerp_recordings to more prominent places Fixes https://github.com/fonttools/fonttools/issues/3361 --- Lib/fontTools/pens/recordingPen.py | 30 +++++++++ Lib/fontTools/ttLib/ttGlyphSet.py | 62 +++++++++++++++++++ Lib/fontTools/varLib/interpolatable.py | 10 ++- Lib/fontTools/varLib/interpolatableHelpers.py | 50 +-------------- Lib/fontTools/varLib/interpolatablePlot.py | 4 +- Tests/ttLib/ttGlyphSet_test.py | 48 ++++++++++++++ 6 files changed, 151 insertions(+), 53 deletions(-) diff --git a/Lib/fontTools/pens/recordingPen.py b/Lib/fontTools/pens/recordingPen.py index 2ed8d32ec..1a500f932 100644 --- a/Lib/fontTools/pens/recordingPen.py +++ b/Lib/fontTools/pens/recordingPen.py @@ -172,6 +172,36 @@ class RecordingPointPen(AbstractPointPen): drawPoints = replay +def lerp_recordings(recording1, recording2, factor=0.5): + """Linearly interpolate between two recordings. The recordings + must be decomposed, i.e. they must not contain any components. + + Factor is typically between 0 and 1. 0 means the first recording, + 1 means the second recording, and 0.5 means the average of the + two recordings. Other values are possible, and can be useful to + extrapolate. Defaults to 0.5. + + Returns the new recording. + """ + value = [] + if len(recording1) != len(recording2): + raise ValueError( + "Mismatched lengths: %d and %d" % (len(recording1), len(recording2)) + ) + for (op1, args1), (op2, args2) in zip(recording1, recording2): + if op1 != op2: + raise ValueError("Mismatched operations: %s, %s" % (op1, op2)) + if op1 == "addComponent": + raise ValueError("Cannot interpolate components") + else: + mid_args = [ + (x1 + (x2 - x1) * factor, y1 + (y2 - y1) * factor) + for (x1, y1), (x2, y2) in zip(args1, args2) + ] + value.append((op1, mid_args)) + return value + + if __name__ == "__main__": pen = RecordingPen() pen.moveTo((0, 0)) diff --git a/Lib/fontTools/ttLib/ttGlyphSet.py b/Lib/fontTools/ttLib/ttGlyphSet.py index 349cc2c73..2826a621b 100644 --- a/Lib/fontTools/ttLib/ttGlyphSet.py +++ b/Lib/fontTools/ttLib/ttGlyphSet.py @@ -9,6 +9,7 @@ from fontTools.misc.fixedTools import otRound from fontTools.misc.loggingTools import deprecateFunction from fontTools.misc.transform import Transform from fontTools.pens.transformPen import TransformPen, TransformPointPen +from fontTools.pens.recordingPen import DecomposingRecordingPen class _TTGlyphSet(Mapping): @@ -321,3 +322,64 @@ def _setCoordinates(glyph, coord, glyfTable, *, recalcBounds=True): verticalAdvanceWidth, topSideBearing, ) + + +class LerpGlyphSet(Mapping): + """A glyphset that interpolates between two other glyphsets. + + Factor is typically between 0 and 1. 0 means the first glyphset, + 1 means the second glyphset, and 0.5 means the average of the + two glyphsets. Other values are possible, and can be useful to + extrapolate. Defaults to 0.5. + """ + + def __init__(self, glyphset1, glyphset2, factor=0.5): + self.glyphset1 = glyphset1 + self.glyphset2 = glyphset2 + self.factor = factor + + def __getitem__(self, glyphname): + if glyphname in self.glyphset1 and glyphname in self.glyphset2: + return LerpGlyph(glyphname, self) + raise KeyError(glyphname) + + def __contains__(self, glyphname): + return glyphname in self.glyphset1 and glyphname in self.glyphset2 + + def __iter__(self): + set1 = set(self.glyphset1) + set2 = set(self.glyphset2) + return iter(set1.intersection(set2)) + + def __len__(self): + set1 = set(self.glyphset1) + set2 = set(self.glyphset2) + return len(set1.intersection(set2)) + + +class LerpGlyph: + def __init__(self, glyphname, glyphset): + self.glyphset = glyphset + self.glyphname = glyphname + + def draw(self, pen): + recording1 = DecomposingRecordingPen(self.glyphset.glyphset1) + self.glyphset.glyphset1[self.glyphname].draw(recording1) + recording2 = DecomposingRecordingPen(self.glyphset.glyphset2) + self.glyphset.glyphset2[self.glyphname].draw(recording2) + + factor = self.glyphset.factor + + if len(recording1.value) != len(recording2.value): + raise ValueError( + "Mismatching number of operations: %d, %d" + % (len(recording1.value), len(recording2.value)) + ) + for (op1, args1), (op2, args2) in zip(recording1.value, recording2.value): + if op1 != op2: + raise ValueError("Mismatching operations: %s, %s" % (op1, op2)) + mid_args = [ + (x1 + (x2 - x1) * factor, y1 + (y2 - y1) * factor) + for (x1, y1), (x2, y2) in zip(args1, args2) + ] + getattr(pen, op1)(*mid_args) diff --git a/Lib/fontTools/varLib/interpolatable.py b/Lib/fontTools/varLib/interpolatable.py index 263d0f999..5f658e63b 100644 --- a/Lib/fontTools/varLib/interpolatable.py +++ b/Lib/fontTools/varLib/interpolatable.py @@ -9,7 +9,11 @@ $ fonttools varLib.interpolatable font1 font2 ... from .interpolatableHelpers import * from .interpolatableTestContourOrder import test_contour_order from .interpolatableTestStartingPoint import test_starting_point -from fontTools.pens.recordingPen import RecordingPen, DecomposingRecordingPen +from fontTools.pens.recordingPen import ( + RecordingPen, + DecomposingRecordingPen, + lerp_recordings, +) from fontTools.pens.transformPen import TransformPen from fontTools.pens.statisticsPen import StatisticsPen, StatisticsControlPen from fontTools.pens.momentsPen import OpenContourError @@ -332,7 +336,9 @@ def test_gen( midRecording = [] for c0, c1 in zip(recording0, recording1): try: - midRecording.append(lerp_recordings(c0, c1)) + r = RecordingPen() + r.value = lerp_recordings(c0.value, c1.value) + midRecording.append(r) except ValueError: # Mismatch because of the reordering above midRecording.append(None) diff --git a/Lib/fontTools/varLib/interpolatableHelpers.py b/Lib/fontTools/varLib/interpolatableHelpers.py index 085357f7e..699023168 100644 --- a/Lib/fontTools/varLib/interpolatableHelpers.py +++ b/Lib/fontTools/varLib/interpolatableHelpers.py @@ -1,3 +1,4 @@ +from fontTools.ttLib.ttGlyphSet import LerpGlyphSet from fontTools.pens.basePen import AbstractPen, BasePen, DecomposingPen from fontTools.pens.pointPen import AbstractPointPen, SegmentToPointPen from fontTools.pens.recordingPen import RecordingPen, DecomposingRecordingPen @@ -374,52 +375,3 @@ def transform_from_stats(stats, inverse=False): trans = trans.translate(stats.meanX, stats.meanY) return trans - - -class LerpGlyphSet: - def __init__(self, glyphset1, glyphset2, factor=0.5): - self.glyphset1 = glyphset1 - self.glyphset2 = glyphset2 - self.factor = factor - - def __getitem__(self, glyphname): - return LerpGlyph(glyphname, self) - - -class LerpGlyph: - def __init__(self, glyphname, glyphset): - self.glyphset = glyphset - self.glyphname = glyphname - - def draw(self, pen): - recording1 = DecomposingRecordingPen(self.glyphset.glyphset1) - self.glyphset.glyphset1[self.glyphname].draw(recording1) - recording2 = DecomposingRecordingPen(self.glyphset.glyphset2) - self.glyphset.glyphset2[self.glyphname].draw(recording2) - - factor = self.glyphset.factor - for (op1, args1), (op2, args2) in zip(recording1.value, recording2.value): - if op1 != op2: - raise ValueError("Mismatching operations: %s, %s" % (op1, op2)) - mid_args = [ - (x1 + (x2 - x1) * factor, y1 + (y2 - y1) * factor) - for (x1, y1), (x2, y2) in zip(args1, args2) - ] - getattr(pen, op1)(*mid_args) - - -def lerp_recordings(recording1, recording2, factor=0.5): - pen = RecordingPen() - value = pen.value - for (op1, args1), (op2, args2) in zip(recording1.value, recording2.value): - if op1 != op2: - raise ValueError("Mismatched operations: %s, %s" % (op1, op2)) - if op1 == "addComponent": - mid_args = args1 # XXX Interpolate transformation? - else: - mid_args = [ - (x1 + (x2 - x1) * factor, y1 + (y2 - y1) * factor) - for (x1, y1), (x2, y2) in zip(args1, args2) - ] - value.append((op1, mid_args)) - return pen diff --git a/Lib/fontTools/varLib/interpolatablePlot.py b/Lib/fontTools/varLib/interpolatablePlot.py index e5c446a39..4c8b5e2f6 100644 --- a/Lib/fontTools/varLib/interpolatablePlot.py +++ b/Lib/fontTools/varLib/interpolatablePlot.py @@ -1,5 +1,6 @@ from .interpolatableHelpers import * from fontTools.ttLib import TTFont +from fontTools.ttLib.ttGlyphSet import LerpGlyphSet from fontTools.pens.recordingPen import ( RecordingPen, DecomposingRecordingPen, @@ -12,10 +13,9 @@ from fontTools.pens.pointPen import ( PointToSegmentPen, ReverseContourPointPen, ) -from fontTools.varLib.interpolatable import ( +from fontTools.varLib.interpolatableHelpers import ( PerContourOrComponentPen, SimpleRecordingPointPen, - LerpGlyphSet, ) from itertools import cycle from functools import wraps diff --git a/Tests/ttLib/ttGlyphSet_test.py b/Tests/ttLib/ttGlyphSet_test.py index 56514464b..177b8a4e7 100644 --- a/Tests/ttLib/ttGlyphSet_test.py +++ b/Tests/ttLib/ttGlyphSet_test.py @@ -1,5 +1,6 @@ from fontTools.ttLib import TTFont from fontTools.ttLib import ttGlyphSet +from fontTools.ttLib.ttGlyphSet import LerpGlyphSet from fontTools.pens.recordingPen import ( RecordingPen, RecordingPointPen, @@ -164,6 +165,53 @@ class TTGlyphSetTest(object): assert actual == expected, (location, actual, expected) + @pytest.mark.parametrize( + "fontfile, locations, factor, expected", + [ + ( + "I.ttf", + ({"wght": 400}, {"wght": 1000}), + 0.5, + [ + ("moveTo", ((151.5, 0.0),)), + ("lineTo", ((458.5, 0.0),)), + ("lineTo", ((458.5, 1456.0),)), + ("lineTo", ((151.5, 1456.0),)), + ("closePath", ()), + ], + ), + ( + "I.ttf", + ({"wght": 400}, {"wght": 1000}), + 0.25, + [ + ("moveTo", ((163.25, 0.0),)), + ("lineTo", ((412.75, 0.0),)), + ("lineTo", ((412.75, 1456.0),)), + ("lineTo", ((163.25, 1456.0),)), + ("closePath", ()), + ], + ), + ], + ) + def test_lerp_glyphset(self, fontfile, locations, factor, expected): + font = TTFont(self.getpath(fontfile)) + glyphset1 = font.getGlyphSet(location=locations[0]) + glyphset2 = font.getGlyphSet(location=locations[1]) + glyphset = LerpGlyphSet(glyphset1, glyphset2, factor) + + assert "I" in glyphset + + pen = RecordingPen() + glyph = glyphset["I"] + + assert glyphset.get("foobar") is None + + glyph.draw(pen) + actual = pen.value + + assert actual == expected, (locations, actual, expected) + def test_glyphset_varComposite_components(self): font = TTFont(self.getpath("varc-ac00-ac01.ttf")) glyphset = font.getGlyphSet()