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