• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1from fontTools.misc.fixedTools import otRound
2from fontTools.misc.testTools import getXML, parseXML
3from fontTools.misc.transform import Transform
4from fontTools.pens.ttGlyphPen import TTGlyphPen
5from fontTools.pens.recordingPen import RecordingPen, RecordingPointPen
6from fontTools.pens.pointPen import PointToSegmentPen
7from fontTools.ttLib import TTFont, newTable, TTLibError
8from fontTools.ttLib.tables._g_l_y_f import (
9    Glyph,
10    GlyphCoordinates,
11    GlyphComponent,
12    dropImpliedOnCurvePoints,
13    flagOnCurve,
14    flagCubic,
15    ARGS_ARE_XY_VALUES,
16    SCALED_COMPONENT_OFFSET,
17    UNSCALED_COMPONENT_OFFSET,
18    WE_HAVE_A_SCALE,
19    WE_HAVE_A_TWO_BY_TWO,
20    WE_HAVE_AN_X_AND_Y_SCALE,
21)
22from fontTools.ttLib.tables import ttProgram
23import sys
24import array
25from copy import deepcopy
26from io import StringIO, BytesIO
27import itertools
28import pytest
29import re
30import os
31import unittest
32
33
34class GlyphCoordinatesTest(object):
35    def test_translate(self):
36        g = GlyphCoordinates([(1, 2)])
37        g.translate((0.5, 0))
38        assert g == GlyphCoordinates([(1.5, 2.0)])
39
40    def test_scale(self):
41        g = GlyphCoordinates([(1, 2)])
42        g.scale((0.5, 0))
43        assert g == GlyphCoordinates([(0.5, 0.0)])
44
45    def test_transform(self):
46        g = GlyphCoordinates([(1, 2)])
47        g.transform(((0.5, 0), (0.2, 0.5)))
48        assert g[0] == GlyphCoordinates([(0.9, 1.0)])[0]
49
50    def test__eq__(self):
51        g = GlyphCoordinates([(1, 2)])
52        g2 = GlyphCoordinates([(1.0, 2)])
53        g3 = GlyphCoordinates([(1.5, 2)])
54        assert g == g2
55        assert not g == g3
56        assert not g2 == g3
57        assert not g == object()
58
59    def test__ne__(self):
60        g = GlyphCoordinates([(1, 2)])
61        g2 = GlyphCoordinates([(1.0, 2)])
62        g3 = GlyphCoordinates([(1.5, 2)])
63        assert not (g != g2)
64        assert g != g3
65        assert g2 != g3
66        assert g != object()
67
68    def test__pos__(self):
69        g = GlyphCoordinates([(1, 2)])
70        g2 = +g
71        assert g == g2
72
73    def test__neg__(self):
74        g = GlyphCoordinates([(1, 2)])
75        g2 = -g
76        assert g2 == GlyphCoordinates([(-1, -2)])
77
78    @pytest.mark.skipif(sys.version_info[0] < 3, reason="__round___ requires Python 3")
79    def test__round__(self):
80        g = GlyphCoordinates([(-1.5, 2)])
81        g2 = round(g)
82        assert g2 == GlyphCoordinates([(-1, 2)])
83
84    def test__add__(self):
85        g1 = GlyphCoordinates([(1, 2)])
86        g2 = GlyphCoordinates([(3, 4)])
87        g3 = GlyphCoordinates([(4, 6)])
88        assert g1 + g2 == g3
89        assert g1 + (1, 1) == GlyphCoordinates([(2, 3)])
90        with pytest.raises(TypeError) as excinfo:
91            assert g1 + object()
92        assert "unsupported operand" in str(excinfo.value)
93
94    def test__sub__(self):
95        g1 = GlyphCoordinates([(1, 2)])
96        g2 = GlyphCoordinates([(3, 4)])
97        g3 = GlyphCoordinates([(-2, -2)])
98        assert g1 - g2 == g3
99        assert g1 - (1, 1) == GlyphCoordinates([(0, 1)])
100        with pytest.raises(TypeError) as excinfo:
101            assert g1 - object()
102        assert "unsupported operand" in str(excinfo.value)
103
104    def test__rsub__(self):
105        g = GlyphCoordinates([(1, 2)])
106        # other + (-self)
107        assert (1, 1) - g == GlyphCoordinates([(0, -1)])
108
109    def test__mul__(self):
110        g = GlyphCoordinates([(1, 2)])
111        assert g * 3 == GlyphCoordinates([(3, 6)])
112        assert g * (3, 2) == GlyphCoordinates([(3, 4)])
113        assert g * (1, 1) == g
114        with pytest.raises(TypeError) as excinfo:
115            assert g * object()
116        assert "unsupported operand" in str(excinfo.value)
117
118    def test__truediv__(self):
119        g = GlyphCoordinates([(1, 2)])
120        assert g / 2 == GlyphCoordinates([(0.5, 1)])
121        assert g / (1, 2) == GlyphCoordinates([(1, 1)])
122        assert g / (1, 1) == g
123        with pytest.raises(TypeError) as excinfo:
124            assert g / object()
125        assert "unsupported operand" in str(excinfo.value)
126
127    def test__iadd__(self):
128        g = GlyphCoordinates([(1, 2)])
129        g += (0.5, 0)
130        assert g == GlyphCoordinates([(1.5, 2.0)])
131        g2 = GlyphCoordinates([(3, 4)])
132        g += g2
133        assert g == GlyphCoordinates([(4.5, 6.0)])
134
135    def test__isub__(self):
136        g = GlyphCoordinates([(1, 2)])
137        g -= (0.5, 0)
138        assert g == GlyphCoordinates([(0.5, 2.0)])
139        g2 = GlyphCoordinates([(3, 4)])
140        g -= g2
141        assert g == GlyphCoordinates([(-2.5, -2.0)])
142
143    def __test__imul__(self):
144        g = GlyphCoordinates([(1, 2)])
145        g *= (2, 0.5)
146        g *= 2
147        assert g == GlyphCoordinates([(4.0, 2.0)])
148        g = GlyphCoordinates([(1, 2)])
149        g *= 2
150        assert g == GlyphCoordinates([(2, 4)])
151
152    def test__itruediv__(self):
153        g = GlyphCoordinates([(1, 3)])
154        g /= (0.5, 1.5)
155        g /= 2
156        assert g == GlyphCoordinates([(1.0, 1.0)])
157
158    def test__bool__(self):
159        g = GlyphCoordinates([])
160        assert bool(g) == False
161        g = GlyphCoordinates([(0, 0), (0.0, 0)])
162        assert bool(g) == True
163        g = GlyphCoordinates([(0, 0), (1, 0)])
164        assert bool(g) == True
165        g = GlyphCoordinates([(0, 0.5), (0, 0)])
166        assert bool(g) == True
167
168    def test_double_precision_float(self):
169        # https://github.com/fonttools/fonttools/issues/963
170        afloat = 242.50000000000003
171        g = GlyphCoordinates([(afloat, 0)])
172        g.toInt()
173        # this would return 242 if the internal array.array typecode is 'f',
174        # since the Python float is truncated to a C float.
175        # when using typecode 'd' it should return the correct value 243
176        assert g[0][0] == otRound(afloat)
177
178    def test__checkFloat_overflow(self):
179        g = GlyphCoordinates([(1, 1)])
180        g.append((0x8000, 0))
181        assert list(g.array) == [1.0, 1.0, 32768.0, 0.0]
182
183
184CURR_DIR = os.path.abspath(os.path.dirname(os.path.realpath(__file__)))
185DATA_DIR = os.path.join(CURR_DIR, "data")
186
187GLYF_TTX = os.path.join(DATA_DIR, "_g_l_y_f_outline_flag_bit6.ttx")
188GLYF_BIN = os.path.join(DATA_DIR, "_g_l_y_f_outline_flag_bit6.glyf.bin")
189HEAD_BIN = os.path.join(DATA_DIR, "_g_l_y_f_outline_flag_bit6.head.bin")
190LOCA_BIN = os.path.join(DATA_DIR, "_g_l_y_f_outline_flag_bit6.loca.bin")
191MAXP_BIN = os.path.join(DATA_DIR, "_g_l_y_f_outline_flag_bit6.maxp.bin")
192INST_TTX = os.path.join(DATA_DIR, "_g_l_y_f_instructions.ttx")
193
194
195def strip_ttLibVersion(string):
196    return re.sub(' ttLibVersion=".*"', "", string)
197
198
199class GlyfTableTest(unittest.TestCase):
200    def __init__(self, methodName):
201        unittest.TestCase.__init__(self, methodName)
202        # Python 3 renamed assertRaisesRegexp to assertRaisesRegex,
203        # and fires deprecation warnings if a program uses the old name.
204        if not hasattr(self, "assertRaisesRegex"):
205            self.assertRaisesRegex = self.assertRaisesRegexp
206
207    @classmethod
208    def setUpClass(cls):
209        with open(GLYF_BIN, "rb") as f:
210            cls.glyfData = f.read()
211        with open(HEAD_BIN, "rb") as f:
212            cls.headData = f.read()
213        with open(LOCA_BIN, "rb") as f:
214            cls.locaData = f.read()
215        with open(MAXP_BIN, "rb") as f:
216            cls.maxpData = f.read()
217        with open(GLYF_TTX, "r") as f:
218            cls.glyfXML = strip_ttLibVersion(f.read()).splitlines()
219
220    def test_toXML(self):
221        font = TTFont(sfntVersion="\x00\x01\x00\x00")
222        glyfTable = font["glyf"] = newTable("glyf")
223        font["head"] = newTable("head")
224        font["loca"] = newTable("loca")
225        font["maxp"] = newTable("maxp")
226        font["maxp"].decompile(self.maxpData, font)
227        font["head"].decompile(self.headData, font)
228        font["loca"].decompile(self.locaData, font)
229        glyfTable.decompile(self.glyfData, font)
230        out = StringIO()
231        font.saveXML(out)
232        glyfXML = strip_ttLibVersion(out.getvalue()).splitlines()
233        self.assertEqual(glyfXML, self.glyfXML)
234
235    def test_fromXML(self):
236        font = TTFont(sfntVersion="\x00\x01\x00\x00")
237        font.importXML(GLYF_TTX)
238        glyfTable = font["glyf"]
239        glyfData = glyfTable.compile(font)
240        self.assertEqual(glyfData, self.glyfData)
241
242    def test_instructions_roundtrip(self):
243        font = TTFont(sfntVersion="\x00\x01\x00\x00")
244        font.importXML(INST_TTX)
245        glyfTable = font["glyf"]
246        self.glyfData = glyfTable.compile(font)
247        out = StringIO()
248        font.saveXML(out)
249        glyfXML = strip_ttLibVersion(out.getvalue()).splitlines()
250        with open(INST_TTX, "r") as f:
251            origXML = strip_ttLibVersion(f.read()).splitlines()
252        self.assertEqual(glyfXML, origXML)
253
254    def test_recursiveComponent(self):
255        glyphSet = {}
256        pen_dummy = TTGlyphPen(glyphSet)
257        glyph_dummy = pen_dummy.glyph()
258        glyphSet["A"] = glyph_dummy
259        glyphSet["B"] = glyph_dummy
260        pen_A = TTGlyphPen(glyphSet)
261        pen_A.addComponent("B", (1, 0, 0, 1, 0, 0))
262        pen_B = TTGlyphPen(glyphSet)
263        pen_B.addComponent("A", (1, 0, 0, 1, 0, 0))
264        glyph_A = pen_A.glyph()
265        glyph_B = pen_B.glyph()
266        glyphSet["A"] = glyph_A
267        glyphSet["B"] = glyph_B
268        with self.assertRaisesRegex(
269            TTLibError, "glyph '.' contains a recursive component reference"
270        ):
271            glyph_A.getCoordinates(glyphSet)
272
273    def test_trim_remove_hinting_composite_glyph(self):
274        glyphSet = {"dummy": TTGlyphPen(None).glyph()}
275
276        pen = TTGlyphPen(glyphSet)
277        pen.addComponent("dummy", (1, 0, 0, 1, 0, 0))
278        composite = pen.glyph()
279        p = ttProgram.Program()
280        p.fromAssembly(["SVTCA[0]"])
281        composite.program = p
282        glyphSet["composite"] = composite
283
284        glyfTable = newTable("glyf")
285        glyfTable.glyphs = glyphSet
286        glyfTable.glyphOrder = sorted(glyphSet)
287
288        composite.compact(glyfTable)
289
290        self.assertTrue(hasattr(composite, "data"))
291
292        # remove hinting from the compacted composite glyph, without expanding it
293        composite.trim(remove_hinting=True)
294
295        # check that, after expanding the glyph, we have no instructions
296        composite.expand(glyfTable)
297        self.assertFalse(hasattr(composite, "program"))
298
299        # now remove hinting from expanded composite glyph
300        composite.program = p
301        composite.trim(remove_hinting=True)
302
303        # check we have no instructions
304        self.assertFalse(hasattr(composite, "program"))
305
306        composite.compact(glyfTable)
307
308    def test_bit6_draw_to_pen_issue1771(self):
309        # https://github.com/fonttools/fonttools/issues/1771
310        font = TTFont(sfntVersion="\x00\x01\x00\x00")
311        # glyph00003 contains a bit 6 flag on the first point,
312        # which triggered the issue
313        font.importXML(GLYF_TTX)
314        glyfTable = font["glyf"]
315        pen = RecordingPen()
316        glyfTable["glyph00003"].draw(pen, glyfTable=glyfTable)
317        expected = [
318            ("moveTo", ((501, 1430),)),
319            ("lineTo", ((683, 1430),)),
320            ("lineTo", ((1172, 0),)),
321            ("lineTo", ((983, 0),)),
322            ("lineTo", ((591, 1193),)),
323            ("lineTo", ((199, 0),)),
324            ("lineTo", ((12, 0),)),
325            ("closePath", ()),
326            ("moveTo", ((249, 514),)),
327            ("lineTo", ((935, 514),)),
328            ("lineTo", ((935, 352),)),
329            ("lineTo", ((249, 352),)),
330            ("closePath", ()),
331        ]
332        self.assertEqual(pen.value, expected)
333
334    def test_bit6_draw_to_pointpen(self):
335        # https://github.com/fonttools/fonttools/issues/1771
336        font = TTFont(sfntVersion="\x00\x01\x00\x00")
337        # glyph00003 contains a bit 6 flag on the first point
338        # which triggered the issue
339        font.importXML(GLYF_TTX)
340        glyfTable = font["glyf"]
341        pen = RecordingPointPen()
342        glyfTable["glyph00003"].drawPoints(pen, glyfTable=glyfTable)
343        expected = [
344            ("beginPath", (), {}),
345            ("addPoint", ((501, 1430), "line", False, None), {}),
346            ("addPoint", ((683, 1430), "line", False, None), {}),
347            ("addPoint", ((1172, 0), "line", False, None), {}),
348            ("addPoint", ((983, 0), "line", False, None), {}),
349        ]
350        self.assertEqual(pen.value[: len(expected)], expected)
351
352    def test_draw_vs_drawpoints(self):
353        font = TTFont(sfntVersion="\x00\x01\x00\x00")
354        font.importXML(GLYF_TTX)
355        glyfTable = font["glyf"]
356        pen1 = RecordingPen()
357        pen2 = RecordingPen()
358        glyfTable["glyph00003"].draw(pen1, glyfTable)
359        glyfTable["glyph00003"].drawPoints(PointToSegmentPen(pen2), glyfTable)
360        self.assertEqual(pen1.value, pen2.value)
361
362    def test_compile_empty_table(self):
363        font = TTFont(sfntVersion="\x00\x01\x00\x00")
364        font.importXML(GLYF_TTX)
365        glyfTable = font["glyf"]
366        # set all glyphs to zero contours
367        glyfTable.glyphs = {glyphName: Glyph() for glyphName in font.getGlyphOrder()}
368        glyfData = glyfTable.compile(font)
369        self.assertEqual(glyfData, b"\x00")
370        self.assertEqual(list(font["loca"]), [0] * (font["maxp"].numGlyphs + 1))
371
372    def test_decompile_empty_table(self):
373        font = TTFont()
374        glyphNames = [".notdef", "space"]
375        font.setGlyphOrder(glyphNames)
376        font["loca"] = newTable("loca")
377        font["loca"].locations = [0] * (len(glyphNames) + 1)
378        font["glyf"] = newTable("glyf")
379        font["glyf"].decompile(b"\x00", font)
380        self.assertEqual(len(font["glyf"]), 2)
381        self.assertEqual(font["glyf"][".notdef"].numberOfContours, 0)
382        self.assertEqual(font["glyf"]["space"].numberOfContours, 0)
383
384    def test_getPhantomPoints(self):
385        # https://github.com/fonttools/fonttools/issues/2295
386        font = TTFont()
387        glyphNames = [".notdef"]
388        font.setGlyphOrder(glyphNames)
389        font["loca"] = newTable("loca")
390        font["loca"].locations = [0] * (len(glyphNames) + 1)
391        font["glyf"] = newTable("glyf")
392        font["glyf"].decompile(b"\x00", font)
393        font["hmtx"] = newTable("hmtx")
394        font["hmtx"].metrics = {".notdef": (100, 0)}
395        font["head"] = newTable("head")
396        font["head"].unitsPerEm = 1000
397        with pytest.deprecated_call():
398            self.assertEqual(
399                font["glyf"].getPhantomPoints(".notdef", font, 0),
400                [(0, 0), (100, 0), (0, 0), (0, -1000)],
401            )
402
403    def test_getGlyphID(self):
404        # https://github.com/fonttools/fonttools/pull/3301#discussion_r1360405861
405        glyf = newTable("glyf")
406        glyf.setGlyphOrder([".notdef", "a", "b"])
407        glyf.glyphs = {}
408        for glyphName in glyf.glyphOrder:
409            glyf[glyphName] = Glyph()
410
411        assert glyf.getGlyphID("a") == 1
412
413        with pytest.raises(ValueError):
414            glyf.getGlyphID("c")
415
416        glyf["c"] = Glyph()
417        assert glyf.getGlyphID("c") == 3
418
419        del glyf["b"]
420        assert glyf.getGlyphID("c") == 2
421
422
423class GlyphTest:
424    def test_getCoordinates(self):
425        glyphSet = {}
426        pen = TTGlyphPen(glyphSet)
427        pen.moveTo((0, 0))
428        pen.lineTo((100, 0))
429        pen.lineTo((100, 100))
430        pen.lineTo((0, 100))
431        pen.closePath()
432        # simple contour glyph
433        glyphSet["a"] = a = pen.glyph()
434
435        assert a.getCoordinates(glyphSet) == (
436            GlyphCoordinates([(0, 0), (100, 0), (100, 100), (0, 100)]),
437            [3],
438            array.array("B", [1, 1, 1, 1]),
439        )
440
441        # composite glyph with only XY offset
442        pen = TTGlyphPen(glyphSet)
443        pen.addComponent("a", (1, 0, 0, 1, 10, 20))
444        glyphSet["b"] = b = pen.glyph()
445
446        assert b.getCoordinates(glyphSet) == (
447            GlyphCoordinates([(10, 20), (110, 20), (110, 120), (10, 120)]),
448            [3],
449            array.array("B", [1, 1, 1, 1]),
450        )
451
452        # composite glyph with a scale (and referencing another composite glyph)
453        pen = TTGlyphPen(glyphSet)
454        pen.addComponent("b", (0.5, 0, 0, 0.5, 0, 0))
455        glyphSet["c"] = c = pen.glyph()
456
457        assert c.getCoordinates(glyphSet) == (
458            GlyphCoordinates([(5, 10), (55, 10), (55, 60), (5, 60)]),
459            [3],
460            array.array("B", [1, 1, 1, 1]),
461        )
462
463        # composite glyph with unscaled offset (MS-style)
464        pen = TTGlyphPen(glyphSet)
465        pen.addComponent("a", (0.5, 0, 0, 0.5, 10, 20))
466        glyphSet["d"] = d = pen.glyph()
467        d.components[0].flags |= UNSCALED_COMPONENT_OFFSET
468
469        assert d.getCoordinates(glyphSet) == (
470            GlyphCoordinates([(10, 20), (60, 20), (60, 70), (10, 70)]),
471            [3],
472            array.array("B", [1, 1, 1, 1]),
473        )
474
475        # composite glyph with a scaled offset (Apple-style)
476        pen = TTGlyphPen(glyphSet)
477        pen.addComponent("a", (0.5, 0, 0, 0.5, 10, 20))
478        glyphSet["e"] = e = pen.glyph()
479        e.components[0].flags |= SCALED_COMPONENT_OFFSET
480
481        assert e.getCoordinates(glyphSet) == (
482            GlyphCoordinates([(5, 10), (55, 10), (55, 60), (5, 60)]),
483            [3],
484            array.array("B", [1, 1, 1, 1]),
485        )
486
487        # composite glyph where the 2nd and 3rd components use anchor points
488        pen = TTGlyphPen(glyphSet)
489        pen.addComponent("a", (1, 0, 0, 1, 0, 0))
490        glyphSet["f"] = f = pen.glyph()
491
492        comp1 = GlyphComponent()
493        comp1.glyphName = "a"
494        # aling the new component's pt 0 to pt 2 of contour points added so far
495        comp1.firstPt = 2
496        comp1.secondPt = 0
497        comp1.flags = 0
498        f.components.append(comp1)
499
500        comp2 = GlyphComponent()
501        comp2.glyphName = "a"
502        # aling the new component's pt 0 to pt 6 of contour points added so far
503        comp2.firstPt = 6
504        comp2.secondPt = 0
505        comp2.transform = [[0.707107, 0.707107], [-0.707107, 0.707107]]  # rotate 45 deg
506        comp2.flags = WE_HAVE_A_TWO_BY_TWO
507        f.components.append(comp2)
508
509        coords, end_pts, flags = f.getCoordinates(glyphSet)
510        assert end_pts == [3, 7, 11]
511        assert flags == array.array("B", [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1])
512        assert list(sum(coords, ())) == pytest.approx(
513            [
514                0,
515                0,
516                100,
517                0,
518                100,
519                100,
520                0,
521                100,
522                100,
523                100,
524                200,
525                100,
526                200,
527                200,
528                100,
529                200,
530                200,
531                200,
532                270.7107,
533                270.7107,
534                200.0,
535                341.4214,
536                129.2893,
537                270.7107,
538            ]
539        )
540
541    def test_getCompositeMaxpValues(self):
542        # https://github.com/fonttools/fonttools/issues/2044
543        glyphSet = {}
544        pen = TTGlyphPen(glyphSet)  # empty non-composite glyph
545        glyphSet["fraction"] = pen.glyph()
546        glyphSet["zero.numr"] = pen.glyph()
547        pen = TTGlyphPen(glyphSet)
548        pen.addComponent("zero.numr", (1, 0, 0, 1, 0, 0))
549        glyphSet["zero.dnom"] = pen.glyph()
550        pen = TTGlyphPen(glyphSet)
551        pen.addComponent("zero.numr", (1, 0, 0, 1, 0, 0))
552        pen.addComponent("fraction", (1, 0, 0, 1, 0, 0))
553        pen.addComponent("zero.dnom", (1, 0, 0, 1, 0, 0))
554        glyphSet["percent"] = pen.glyph()
555        pen = TTGlyphPen(glyphSet)
556        pen.addComponent("zero.numr", (1, 0, 0, 1, 0, 0))
557        pen.addComponent("fraction", (1, 0, 0, 1, 0, 0))
558        pen.addComponent("zero.dnom", (1, 0, 0, 1, 0, 0))
559        pen.addComponent("zero.dnom", (1, 0, 0, 1, 0, 0))
560        glyphSet["perthousand"] = pen.glyph()
561        assert glyphSet["zero.dnom"].getCompositeMaxpValues(glyphSet)[2] == 1
562        assert glyphSet["percent"].getCompositeMaxpValues(glyphSet)[2] == 2
563        assert glyphSet["perthousand"].getCompositeMaxpValues(glyphSet)[2] == 2
564
565    def test_recalcBounds_empty_components(self):
566        glyphSet = {}
567        pen = TTGlyphPen(glyphSet)
568        # empty simple glyph
569        foo = glyphSet["foo"] = pen.glyph()
570        # use the empty 'foo' glyph as a component in 'bar' with some x/y offsets
571        pen.addComponent("foo", (1, 0, 0, 1, -80, 50))
572        bar = glyphSet["bar"] = pen.glyph()
573
574        foo.recalcBounds(glyphSet)
575        bar.recalcBounds(glyphSet)
576
577        # we expect both the empty simple glyph and the composite referencing it
578        # to have empty bounding boxes (0, 0, 0, 0) no matter the component's shift
579        assert (foo.xMin, foo.yMin, foo.xMax, foo.yMax) == (0, 0, 0, 0)
580        assert (bar.xMin, bar.yMin, bar.xMax, bar.yMax) == (0, 0, 0, 0)
581
582
583class GlyphComponentTest:
584    def test_toXML_no_transform(self):
585        comp = GlyphComponent()
586        comp.glyphName = "a"
587        comp.flags = ARGS_ARE_XY_VALUES
588        comp.x, comp.y = 1, 2
589
590        assert getXML(comp.toXML) == [
591            '<component glyphName="a" x="1" y="2" flags="0x2"/>'
592        ]
593
594    def test_toXML_transform_scale(self):
595        comp = GlyphComponent()
596        comp.glyphName = "a"
597        comp.flags = ARGS_ARE_XY_VALUES | WE_HAVE_A_SCALE
598        comp.x, comp.y = 1, 2
599
600        comp.transform = [[0.2999878, 0], [0, 0.2999878]]
601        assert getXML(comp.toXML) == [
602            '<component glyphName="a" x="1" y="2" scale="0.3" flags="0xa"/>'
603        ]
604
605    def test_toXML_transform_xy_scale(self):
606        comp = GlyphComponent()
607        comp.glyphName = "a"
608        comp.flags = ARGS_ARE_XY_VALUES | WE_HAVE_AN_X_AND_Y_SCALE
609        comp.x, comp.y = 1, 2
610
611        comp.transform = [[0.5999756, 0], [0, 0.2999878]]
612        assert getXML(comp.toXML) == [
613            '<component glyphName="a" x="1" y="2" scalex="0.6" '
614            'scaley="0.3" flags="0x42"/>'
615        ]
616
617    def test_toXML_transform_2x2_scale(self):
618        comp = GlyphComponent()
619        comp.glyphName = "a"
620        comp.flags = ARGS_ARE_XY_VALUES | WE_HAVE_A_TWO_BY_TWO
621        comp.x, comp.y = 1, 2
622
623        comp.transform = [[0.5999756, -0.2000122], [0.2000122, 0.2999878]]
624        assert getXML(comp.toXML) == [
625            '<component glyphName="a" x="1" y="2" scalex="0.6" scale01="-0.2" '
626            'scale10="0.2" scaley="0.3" flags="0x82"/>'
627        ]
628
629    def test_fromXML_no_transform(self):
630        comp = GlyphComponent()
631        for name, attrs, content in parseXML(
632            ['<component glyphName="a" x="1" y="2" flags="0x2"/>']
633        ):
634            comp.fromXML(name, attrs, content, ttFont=None)
635
636        assert comp.glyphName == "a"
637        assert comp.flags & ARGS_ARE_XY_VALUES != 0
638        assert (comp.x, comp.y) == (1, 2)
639        assert not hasattr(comp, "transform")
640
641    def test_fromXML_transform_scale(self):
642        comp = GlyphComponent()
643        for name, attrs, content in parseXML(
644            ['<component glyphName="a" x="1" y="2" scale="0.3" flags="0xa"/>']
645        ):
646            comp.fromXML(name, attrs, content, ttFont=None)
647
648        assert comp.glyphName == "a"
649        assert comp.flags & ARGS_ARE_XY_VALUES != 0
650        assert comp.flags & WE_HAVE_A_SCALE != 0
651        assert (comp.x, comp.y) == (1, 2)
652        assert hasattr(comp, "transform")
653        for value, expected in zip(
654            itertools.chain(*comp.transform), [0.2999878, 0, 0, 0.2999878]
655        ):
656            assert value == pytest.approx(expected)
657
658    def test_fromXML_transform_xy_scale(self):
659        comp = GlyphComponent()
660        for name, attrs, content in parseXML(
661            [
662                '<component glyphName="a" x="1" y="2" scalex="0.6" '
663                'scaley="0.3" flags="0x42"/>'
664            ]
665        ):
666            comp.fromXML(name, attrs, content, ttFont=None)
667
668        assert comp.glyphName == "a"
669        assert comp.flags & ARGS_ARE_XY_VALUES != 0
670        assert comp.flags & WE_HAVE_AN_X_AND_Y_SCALE != 0
671        assert (comp.x, comp.y) == (1, 2)
672        assert hasattr(comp, "transform")
673        for value, expected in zip(
674            itertools.chain(*comp.transform), [0.5999756, 0, 0, 0.2999878]
675        ):
676            assert value == pytest.approx(expected)
677
678    def test_fromXML_transform_2x2_scale(self):
679        comp = GlyphComponent()
680        for name, attrs, content in parseXML(
681            [
682                '<component glyphName="a" x="1" y="2" scalex="0.6" scale01="-0.2" '
683                'scale10="0.2" scaley="0.3" flags="0x82"/>'
684            ]
685        ):
686            comp.fromXML(name, attrs, content, ttFont=None)
687
688        assert comp.glyphName == "a"
689        assert comp.flags & ARGS_ARE_XY_VALUES != 0
690        assert comp.flags & WE_HAVE_A_TWO_BY_TWO != 0
691        assert (comp.x, comp.y) == (1, 2)
692        assert hasattr(comp, "transform")
693        for value, expected in zip(
694            itertools.chain(*comp.transform),
695            [0.5999756, -0.2000122, 0.2000122, 0.2999878],
696        ):
697            assert value == pytest.approx(expected)
698
699    def test_toXML_reference_points(self):
700        comp = GlyphComponent()
701        comp.glyphName = "a"
702        comp.flags = 0
703        comp.firstPt = 1
704        comp.secondPt = 2
705
706        assert getXML(comp.toXML) == [
707            '<component glyphName="a" firstPt="1" secondPt="2" flags="0x0"/>'
708        ]
709
710    def test_fromXML_reference_points(self):
711        comp = GlyphComponent()
712        for name, attrs, content in parseXML(
713            ['<component glyphName="a" firstPt="1" secondPt="2" flags="0x0"/>']
714        ):
715            comp.fromXML(name, attrs, content, ttFont=None)
716
717        assert comp.glyphName == "a"
718        assert comp.flags == 0
719        assert (comp.firstPt, comp.secondPt) == (1, 2)
720        assert not hasattr(comp, "transform")
721
722    def test_trim_varComposite_glyph(self):
723        font_path = os.path.join(DATA_DIR, "..", "..", "data", "varc-ac00-ac01.ttf")
724        font = TTFont(font_path)
725        glyf = font["glyf"]
726
727        glyf.glyphs["uniAC00"].trim()
728        glyf.glyphs["uniAC01"].trim()
729
730        font_path = os.path.join(DATA_DIR, "..", "..", "data", "varc-6868.ttf")
731        font = TTFont(font_path)
732        glyf = font["glyf"]
733
734        glyf.glyphs["uni6868"].trim()
735
736    def test_varComposite_basic(self):
737        font_path = os.path.join(DATA_DIR, "..", "..", "data", "varc-ac00-ac01.ttf")
738        font = TTFont(font_path)
739        tables = [
740            table_tag
741            for table_tag in font.keys()
742            if table_tag not in {"head", "maxp", "hhea"}
743        ]
744        xml = StringIO()
745        font.saveXML(xml)
746        xml1 = StringIO()
747        font.saveXML(xml1, tables=tables)
748        xml.seek(0)
749        font = TTFont()
750        font.importXML(xml)
751        ttf = BytesIO()
752        font.save(ttf)
753        ttf.seek(0)
754        font = TTFont(ttf)
755        xml2 = StringIO()
756        font.saveXML(xml2, tables=tables)
757        assert xml1.getvalue() == xml2.getvalue()
758
759        font_path = os.path.join(DATA_DIR, "..", "..", "data", "varc-6868.ttf")
760        font = TTFont(font_path)
761        tables = [
762            table_tag
763            for table_tag in font.keys()
764            if table_tag not in {"head", "maxp", "hhea", "name", "fvar"}
765        ]
766        xml = StringIO()
767        font.saveXML(xml)
768        xml1 = StringIO()
769        font.saveXML(xml1, tables=tables)
770        xml.seek(0)
771        font = TTFont()
772        font.importXML(xml)
773        ttf = BytesIO()
774        font.save(ttf)
775        ttf.seek(0)
776        font = TTFont(ttf)
777        xml2 = StringIO()
778        font.saveXML(xml2, tables=tables)
779        assert xml1.getvalue() == xml2.getvalue()
780
781
782class GlyphCubicTest:
783    def test_roundtrip(self):
784        font_path = os.path.join(DATA_DIR, "NotoSans-VF-cubic.subset.ttf")
785        font = TTFont(font_path)
786        tables = [table_tag for table_tag in font.keys() if table_tag not in {"head"}]
787        xml = StringIO()
788        font.saveXML(xml)
789        xml1 = StringIO()
790        font.saveXML(xml1, tables=tables)
791        xml.seek(0)
792        font = TTFont()
793        font.importXML(xml)
794        ttf = BytesIO()
795        font.save(ttf)
796        ttf.seek(0)
797        font = TTFont(ttf)
798        xml2 = StringIO()
799        font.saveXML(xml2, tables=tables)
800        assert xml1.getvalue() == xml2.getvalue()
801
802    def test_no_oncurves(self):
803        glyph = Glyph()
804        glyph.numberOfContours = 1
805        glyph.coordinates = GlyphCoordinates(
806            [(0, 0), (1, 0), (1, 0), (1, 1), (1, 1), (0, 1), (0, 1), (0, 0)]
807        )
808        glyph.flags = array.array("B", [flagCubic] * 8)
809        glyph.endPtsOfContours = [7]
810        glyph.program = ttProgram.Program()
811
812        for i in range(2):
813            if i == 1:
814                glyph.compile(None)
815
816            pen = RecordingPen()
817            glyph.draw(pen, None)
818
819            assert pen.value == [
820                ("moveTo", ((0, 0),)),
821                ("curveTo", ((0, 0), (1, 0), (1, 0))),
822                ("curveTo", ((1, 0), (1, 1), (1, 1))),
823                ("curveTo", ((1, 1), (0, 1), (0, 1))),
824                ("curveTo", ((0, 1), (0, 0), (0, 0))),
825                ("closePath", ()),
826            ]
827
828    def test_spline(self):
829        glyph = Glyph()
830        glyph.numberOfContours = 1
831        glyph.coordinates = GlyphCoordinates(
832            [(0, 0), (1, 0), (1, 0), (1, 1), (1, 1), (0, 1), (0, 1)]
833        )
834        glyph.flags = array.array("B", [flagOnCurve] + [flagCubic] * 6)
835        glyph.endPtsOfContours = [6]
836        glyph.program = ttProgram.Program()
837
838        for i in range(2):
839            if i == 1:
840                glyph.compile(None)
841
842            pen = RecordingPen()
843            glyph.draw(pen, None)
844
845            assert pen.value == [
846                ("moveTo", ((0, 0),)),
847                ("curveTo", ((1, 0), (1, 0), (1.0, 0.5))),
848                ("curveTo", ((1, 1), (1, 1), (0.5, 1.0))),
849                ("curveTo", ((0, 1), (0, 1), (0, 0))),
850                ("closePath", ()),
851            ]
852
853
854def build_interpolatable_glyphs(contours, *transforms):
855    # given a list of lists of (point, flag) tuples (one per contour), build a Glyph
856    # then make len(transforms) copies transformed accordingly, and return a
857    # list of such interpolatable glyphs.
858    glyph1 = Glyph()
859    glyph1.numberOfContours = len(contours)
860    glyph1.coordinates = GlyphCoordinates(
861        [pt for contour in contours for pt, _flag in contour]
862    )
863    glyph1.flags = array.array(
864        "B", [flag for contour in contours for _pt, flag in contour]
865    )
866    glyph1.endPtsOfContours = [
867        sum(len(contour) for contour in contours[: i + 1]) - 1
868        for i in range(len(contours))
869    ]
870    result = [glyph1]
871    for t in transforms:
872        glyph = deepcopy(glyph1)
873        glyph.coordinates.transform((t[0:2], t[2:4]))
874        glyph.coordinates.translate(t[4:6])
875        result.append(glyph)
876    return result
877
878
879def test_dropImpliedOnCurvePoints_all_quad_off_curves():
880    # Two interpolatable glyphs with same structure, the coordinates of one are 2x the
881    # other; all the on-curve points are impliable in each one, thus are dropped from
882    # both, leaving contours with off-curve points only.
883    glyph1, glyph2 = build_interpolatable_glyphs(
884        [
885            [
886                ((0, 1), flagOnCurve),
887                ((1, 1), 0),
888                ((1, 0), flagOnCurve),
889                ((1, -1), 0),
890                ((0, -1), flagOnCurve),
891                ((-1, -1), 0),
892                ((-1, 0), flagOnCurve),
893                ((-1, 1), 0),
894            ],
895            [
896                ((0, 2), flagOnCurve),
897                ((2, 2), 0),
898                ((2, 0), flagOnCurve),
899                ((2, -2), 0),
900                ((0, -2), flagOnCurve),
901                ((-2, -2), 0),
902                ((-2, 0), flagOnCurve),
903                ((-2, 2), 0),
904            ],
905        ],
906        Transform().scale(2.0),
907    )
908    # also add an empty glyph (will be ignored); we use this trick for 'sparse' masters
909    glyph3 = Glyph()
910    glyph3.numberOfContours = 0
911
912    assert dropImpliedOnCurvePoints(glyph1, glyph2, glyph3) == {
913        0,
914        2,
915        4,
916        6,
917        8,
918        10,
919        12,
920        14,
921    }
922
923    assert glyph1.flags == glyph2.flags == array.array("B", [0, 0, 0, 0, 0, 0, 0, 0])
924    assert glyph1.coordinates == GlyphCoordinates(
925        [(1, 1), (1, -1), (-1, -1), (-1, 1), (2, 2), (2, -2), (-2, -2), (-2, 2)]
926    )
927    assert glyph2.coordinates == GlyphCoordinates(
928        [(2, 2), (2, -2), (-2, -2), (-2, 2), (4, 4), (4, -4), (-4, -4), (-4, 4)]
929    )
930    assert glyph1.endPtsOfContours == glyph2.endPtsOfContours == [3, 7]
931    assert glyph3.numberOfContours == 0
932
933
934def test_dropImpliedOnCurvePoints_all_cubic_off_curves():
935    # same as above this time using cubic curves
936    glyph1, glyph2 = build_interpolatable_glyphs(
937        [
938            [
939                ((0, 1), flagOnCurve),
940                ((1, 1), flagCubic),
941                ((1, 1), flagCubic),
942                ((1, 0), flagOnCurve),
943                ((1, -1), flagCubic),
944                ((1, -1), flagCubic),
945                ((0, -1), flagOnCurve),
946                ((-1, -1), flagCubic),
947                ((-1, -1), flagCubic),
948                ((-1, 0), flagOnCurve),
949                ((-1, 1), flagCubic),
950                ((-1, 1), flagCubic),
951            ]
952        ],
953        Transform().translate(10.0),
954    )
955    glyph3 = Glyph()
956    glyph3.numberOfContours = 0
957
958    assert dropImpliedOnCurvePoints(glyph1, glyph2, glyph3) == {0, 3, 6, 9}
959
960    assert glyph1.flags == glyph2.flags == array.array("B", [flagCubic] * 8)
961    assert glyph1.coordinates == GlyphCoordinates(
962        [(1, 1), (1, 1), (1, -1), (1, -1), (-1, -1), (-1, -1), (-1, 1), (-1, 1)]
963    )
964    assert glyph2.coordinates == GlyphCoordinates(
965        [(11, 1), (11, 1), (11, -1), (11, -1), (9, -1), (9, -1), (9, 1), (9, 1)]
966    )
967    assert glyph1.endPtsOfContours == glyph2.endPtsOfContours == [7]
968    assert glyph3.numberOfContours == 0
969
970
971def test_dropImpliedOnCurvePoints_not_all_impliable():
972    # same input as in in test_dropImpliedOnCurvePoints_all_quad_off_curves but we
973    # perturbate one of the glyphs such that the 2nd on-curve is no longer half-way
974    # between the neighboring off-curves.
975    glyph1, glyph2, glyph3 = build_interpolatable_glyphs(
976        [
977            [
978                ((0, 1), flagOnCurve),
979                ((1, 1), 0),
980                ((1, 0), flagOnCurve),
981                ((1, -1), 0),
982                ((0, -1), flagOnCurve),
983                ((-1, -1), 0),
984                ((-1, 0), flagOnCurve),
985                ((-1, 1), 0),
986            ]
987        ],
988        Transform().translate(10.0),
989        Transform().translate(10.0).scale(2.0),
990    )
991    p2 = glyph2.coordinates[2]
992    glyph2.coordinates[2] = (p2[0] + 2.0, p2[1] - 2.0)
993
994    assert dropImpliedOnCurvePoints(glyph1, glyph2, glyph3) == {
995        0,
996        # 2,  this is NOT implied because it's no longer impliable for all glyphs
997        4,
998        6,
999    }
1000
1001    assert glyph2.flags == array.array("B", [0, flagOnCurve, 0, 0, 0])
1002
1003
1004def test_dropImpliedOnCurvePoints_all_empty_glyphs():
1005    glyph1 = Glyph()
1006    glyph1.numberOfContours = 0
1007    glyph2 = Glyph()
1008    glyph2.numberOfContours = 0
1009
1010    assert dropImpliedOnCurvePoints(glyph1, glyph2) == set()
1011
1012
1013def test_dropImpliedOnCurvePoints_incompatible_number_of_contours():
1014    glyph1 = Glyph()
1015    glyph1.numberOfContours = 1
1016    glyph1.endPtsOfContours = [3]
1017    glyph1.flags = array.array("B", [1, 1, 1, 1])
1018    glyph1.coordinates = GlyphCoordinates([(0, 0), (1, 1), (2, 2), (3, 3)])
1019
1020    glyph2 = Glyph()
1021    glyph2.numberOfContours = 2
1022    glyph2.endPtsOfContours = [1, 3]
1023    glyph2.flags = array.array("B", [1, 1, 1, 1])
1024    glyph2.coordinates = GlyphCoordinates([(0, 0), (1, 1), (2, 2), (3, 3)])
1025
1026    with pytest.raises(ValueError, match="Incompatible numberOfContours"):
1027        dropImpliedOnCurvePoints(glyph1, glyph2)
1028
1029
1030def test_dropImpliedOnCurvePoints_incompatible_flags():
1031    glyph1 = Glyph()
1032    glyph1.numberOfContours = 1
1033    glyph1.endPtsOfContours = [3]
1034    glyph1.flags = array.array("B", [1, 1, 1, 1])
1035    glyph1.coordinates = GlyphCoordinates([(0, 0), (1, 1), (2, 2), (3, 3)])
1036
1037    glyph2 = Glyph()
1038    glyph2.numberOfContours = 1
1039    glyph2.endPtsOfContours = [3]
1040    glyph2.flags = array.array("B", [0, 0, 0, 0])
1041    glyph2.coordinates = GlyphCoordinates([(0, 0), (1, 1), (2, 2), (3, 3)])
1042
1043    with pytest.raises(ValueError, match="Incompatible flags"):
1044        dropImpliedOnCurvePoints(glyph1, glyph2)
1045
1046
1047def test_dropImpliedOnCurvePoints_incompatible_endPtsOfContours():
1048    glyph1 = Glyph()
1049    glyph1.numberOfContours = 2
1050    glyph1.endPtsOfContours = [2, 6]
1051    glyph1.flags = array.array("B", [1, 1, 1, 1, 1, 1, 1])
1052    glyph1.coordinates = GlyphCoordinates([(i, i) for i in range(7)])
1053
1054    glyph2 = Glyph()
1055    glyph2.numberOfContours = 2
1056    glyph2.endPtsOfContours = [3, 6]
1057    glyph2.flags = array.array("B", [1, 1, 1, 1, 1, 1, 1])
1058    glyph2.coordinates = GlyphCoordinates([(i, i) for i in range(7)])
1059
1060    with pytest.raises(ValueError, match="Incompatible endPtsOfContours"):
1061        dropImpliedOnCurvePoints(glyph1, glyph2)
1062
1063
1064if __name__ == "__main__":
1065    import sys
1066
1067    sys.exit(unittest.main())
1068