1import os 2import pytest 3from fontTools.designspaceLib import AxisDescriptor 4from fontTools.ttLib import TTFont 5from fontTools.pens.ttGlyphPen import TTGlyphPen 6from fontTools.pens.t2CharStringPen import T2CharStringPen 7from fontTools.fontBuilder import FontBuilder 8from fontTools.ttLib.tables.TupleVariation import TupleVariation 9from fontTools.misc.psCharStrings import T2CharString 10from fontTools.misc.testTools import stripVariableItemsFromTTX 11 12 13def getTestData(fileName, mode="r"): 14 path = os.path.join(os.path.dirname(__file__), "data", fileName) 15 with open(path, mode) as f: 16 return f.read() 17 18 19def drawTestGlyph(pen): 20 pen.moveTo((100, 100)) 21 pen.lineTo((100, 1000)) 22 pen.qCurveTo((200, 900), (400, 900), (500, 1000)) 23 pen.lineTo((500, 100)) 24 pen.closePath() 25 26 27def _setupFontBuilder(isTTF, unitsPerEm=1024): 28 fb = FontBuilder(unitsPerEm, isTTF=isTTF) 29 fb.setupGlyphOrder([".notdef", ".null", "A", "a"]) 30 fb.setupCharacterMap({65: "A", 97: "a"}) 31 32 advanceWidths = {".notdef": 600, "A": 600, "a": 600, ".null": 600} 33 34 familyName = "HelloTestFont" 35 styleName = "TotallyNormal" 36 nameStrings = dict( 37 familyName=dict(en="HelloTestFont", nl="HalloTestFont"), 38 styleName=dict(en="TotallyNormal", nl="TotaalNormaal"), 39 ) 40 nameStrings["psName"] = familyName + "-" + styleName 41 42 return fb, advanceWidths, nameStrings 43 44 45def _setupFontBuilderFvar(fb): 46 assert "name" in fb.font, "Must run setupNameTable() first." 47 48 testAxis = AxisDescriptor() 49 testAxis.name = "Test Axis" 50 testAxis.tag = "TEST" 51 testAxis.minimum = 0 52 testAxis.default = 0 53 testAxis.maximum = 100 54 testAxis.map = [(0, 0), (40, 60), (100, 100)] 55 axes = [testAxis] 56 instances = [ 57 dict(location=dict(TEST=0), stylename="TotallyNormal"), 58 dict(location=dict(TEST=100), stylename="TotallyTested"), 59 ] 60 fb.setupFvar(axes, instances) 61 fb.setupAvar(axes) 62 63 return fb 64 65 66def _setupFontBuilderCFF2(fb): 67 assert "fvar" in fb.font, "Must run _setupFontBuilderFvar() first." 68 69 pen = T2CharStringPen(None, None, CFF2=True) 70 drawTestGlyph(pen) 71 charString = pen.getCharString() 72 73 program = [ 74 200, 75 200, 76 -200, 77 -200, 78 2, 79 "blend", 80 "rmoveto", 81 400, 82 400, 83 1, 84 "blend", 85 "hlineto", 86 400, 87 400, 88 1, 89 "blend", 90 "vlineto", 91 -400, 92 -400, 93 1, 94 "blend", 95 "hlineto", 96 ] 97 charStringVariable = T2CharString(program=program) 98 99 charStrings = { 100 ".notdef": charString, 101 "A": charString, 102 "a": charStringVariable, 103 ".null": charString, 104 } 105 fb.setupCFF2(charStrings, regions=[{"TEST": (0, 1, 1)}]) 106 107 return fb 108 109 110def _verifyOutput(outPath, tables=None): 111 f = TTFont(outPath) 112 f.saveXML(outPath + ".ttx", tables=tables) 113 with open(outPath + ".ttx") as f: 114 testData = stripVariableItemsFromTTX(f.read()) 115 refData = stripVariableItemsFromTTX(getTestData(os.path.basename(outPath) + ".ttx")) 116 assert refData == testData 117 118 119def test_build_ttf(tmpdir): 120 outPath = os.path.join(str(tmpdir), "test.ttf") 121 122 fb, advanceWidths, nameStrings = _setupFontBuilder(True) 123 124 pen = TTGlyphPen(None) 125 drawTestGlyph(pen) 126 glyph = pen.glyph() 127 glyphs = {".notdef": glyph, "A": glyph, "a": glyph, ".null": glyph} 128 fb.setupGlyf(glyphs) 129 metrics = {} 130 glyphTable = fb.font["glyf"] 131 for gn, advanceWidth in advanceWidths.items(): 132 metrics[gn] = (advanceWidth, glyphTable[gn].xMin) 133 fb.setupHorizontalMetrics(metrics) 134 135 fb.setupHorizontalHeader(ascent=824, descent=200) 136 fb.setupNameTable(nameStrings) 137 fb.setupOS2() 138 fb.addOpenTypeFeatures("feature salt { sub A by a; } salt;") 139 fb.setupPost() 140 fb.setupDummyDSIG() 141 142 fb.save(outPath) 143 144 _verifyOutput(outPath) 145 146 147def test_build_cubic_ttf(tmp_path): 148 pen = TTGlyphPen(None) 149 pen.moveTo((100, 100)) 150 pen.curveTo((200, 200), (300, 300), (400, 400)) 151 pen.closePath() 152 glyph = pen.glyph() 153 glyphs = {"A": glyph} 154 155 # cubic outlines are not allowed in glyf table format 0 156 fb = FontBuilder(1000, isTTF=True, glyphDataFormat=0) 157 with pytest.raises( 158 ValueError, match="Glyph 'A' has cubic Bezier outlines, but glyphDataFormat=0" 159 ): 160 fb.setupGlyf(glyphs) 161 # can skip check if feeling adventurous 162 fb.setupGlyf(glyphs, validateGlyphFormat=False) 163 164 # cubics are (will be) allowed in glyf table format 1 165 fb = FontBuilder(1000, isTTF=True, glyphDataFormat=1) 166 fb.setupGlyf(glyphs) 167 assert "A" in fb.font["glyf"].glyphs 168 169 170def test_build_otf(tmpdir): 171 outPath = os.path.join(str(tmpdir), "test.otf") 172 173 fb, advanceWidths, nameStrings = _setupFontBuilder(False) 174 175 pen = T2CharStringPen(600, None) 176 drawTestGlyph(pen) 177 charString = pen.getCharString() 178 charStrings = { 179 ".notdef": charString, 180 "A": charString, 181 "a": charString, 182 ".null": charString, 183 } 184 fb.setupCFF( 185 nameStrings["psName"], {"FullName": nameStrings["psName"]}, charStrings, {} 186 ) 187 188 lsb = {gn: cs.calcBounds(None)[0] for gn, cs in charStrings.items()} 189 metrics = {} 190 for gn, advanceWidth in advanceWidths.items(): 191 metrics[gn] = (advanceWidth, lsb[gn]) 192 fb.setupHorizontalMetrics(metrics) 193 194 fb.setupHorizontalHeader(ascent=824, descent=200) 195 fb.setupNameTable(nameStrings) 196 fb.setupOS2() 197 fb.addOpenTypeFeatures("feature kern { pos A a -50; } kern;") 198 fb.setupPost() 199 fb.setupDummyDSIG() 200 201 fb.save(outPath) 202 203 _verifyOutput(outPath) 204 205 206def test_build_var(tmpdir): 207 outPath = os.path.join(str(tmpdir), "test_var.ttf") 208 209 fb, advanceWidths, nameStrings = _setupFontBuilder(True) 210 211 pen = TTGlyphPen(None) 212 pen.moveTo((100, 0)) 213 pen.lineTo((100, 400)) 214 pen.lineTo((500, 400)) 215 pen.lineTo((500, 000)) 216 pen.closePath() 217 glyph1 = pen.glyph() 218 219 pen = TTGlyphPen(None) 220 pen.moveTo((50, 0)) 221 pen.lineTo((50, 200)) 222 pen.lineTo((250, 200)) 223 pen.lineTo((250, 0)) 224 pen.closePath() 225 glyph2 = pen.glyph() 226 227 pen = TTGlyphPen(None) 228 emptyGlyph = pen.glyph() 229 230 glyphs = {".notdef": emptyGlyph, "A": glyph1, "a": glyph2, ".null": emptyGlyph} 231 fb.setupGlyf(glyphs) 232 metrics = {} 233 glyphTable = fb.font["glyf"] 234 for gn, advanceWidth in advanceWidths.items(): 235 metrics[gn] = (advanceWidth, glyphTable[gn].xMin) 236 fb.setupHorizontalMetrics(metrics) 237 238 fb.setupHorizontalHeader(ascent=824, descent=200) 239 fb.setupNameTable(nameStrings) 240 241 axes = [ 242 ("LEFT", 0, 0, 100, "Left"), 243 ("RGHT", 0, 0, 100, "Right"), 244 ("UPPP", 0, 0, 100, "Up"), 245 ("DOWN", 0, 0, 100, "Down"), 246 ] 247 instances = [ 248 dict(location=dict(LEFT=0, RGHT=0, UPPP=0, DOWN=0), stylename="TotallyNormal"), 249 dict(location=dict(LEFT=0, RGHT=100, UPPP=100, DOWN=0), stylename="Right Up"), 250 ] 251 fb.setupFvar(axes, instances) 252 variations = {} 253 # Four (x, y) pairs and four phantom points: 254 leftDeltas = [(-200, 0), (-200, 0), (0, 0), (0, 0), None, None, None, None] 255 rightDeltas = [(0, 0), (0, 0), (200, 0), (200, 0), None, None, None, None] 256 upDeltas = [(0, 0), (0, 200), (0, 200), (0, 0), None, None, None, None] 257 downDeltas = [(0, -200), (0, 0), (0, 0), (0, -200), None, None, None, None] 258 variations["a"] = [ 259 TupleVariation(dict(RGHT=(0, 1, 1)), rightDeltas), 260 TupleVariation(dict(LEFT=(0, 1, 1)), leftDeltas), 261 TupleVariation(dict(UPPP=(0, 1, 1)), upDeltas), 262 TupleVariation(dict(DOWN=(0, 1, 1)), downDeltas), 263 ] 264 fb.setupGvar(variations) 265 266 fb.addFeatureVariations( 267 [ 268 ( 269 [ 270 {"LEFT": (0.8, 1), "DOWN": (0.8, 1)}, 271 {"RGHT": (0.8, 1), "UPPP": (0.8, 1)}, 272 ], 273 {"A": "a"}, 274 ) 275 ], 276 featureTag="rclt", 277 ) 278 279 statAxes = [] 280 for tag, minVal, defaultVal, maxVal, name in axes: 281 values = [ 282 dict(name="Neutral", value=defaultVal, flags=0x2), 283 dict(name=name, value=maxVal), 284 ] 285 statAxes.append(dict(tag=tag, name=name, values=values)) 286 fb.setupStat(statAxes) 287 288 fb.setupOS2() 289 fb.setupPost() 290 fb.setupDummyDSIG() 291 292 fb.save(outPath) 293 294 _verifyOutput(outPath) 295 296 297def test_build_cff2(tmpdir): 298 outPath = os.path.join(str(tmpdir), "test_var.otf") 299 300 fb, advanceWidths, nameStrings = _setupFontBuilder(False, 1000) 301 fb.setupNameTable(nameStrings) 302 fb = _setupFontBuilderFvar(fb) 303 fb = _setupFontBuilderCFF2(fb) 304 305 metrics = {gn: (advanceWidth, 0) for gn, advanceWidth in advanceWidths.items()} 306 fb.setupHorizontalMetrics(metrics) 307 308 fb.setupHorizontalHeader(ascent=824, descent=200) 309 fb.setupOS2( 310 sTypoAscender=825, sTypoDescender=200, usWinAscent=824, usWinDescent=200 311 ) 312 fb.setupPost() 313 314 fb.save(outPath) 315 316 _verifyOutput(outPath) 317 318 319def test_build_cff_to_cff2(tmpdir): 320 fb, _, _ = _setupFontBuilder(False, 1000) 321 322 pen = T2CharStringPen(600, None) 323 drawTestGlyph(pen) 324 charString = pen.getCharString() 325 charStrings = { 326 ".notdef": charString, 327 "A": charString, 328 "a": charString, 329 ".null": charString, 330 } 331 fb.setupCFF("TestFont", {}, charStrings, {}) 332 333 from fontTools.varLib.cff import convertCFFtoCFF2 334 335 convertCFFtoCFF2(fb.font) 336 337 338def test_setupNameTable_no_mac(): 339 fb, _, nameStrings = _setupFontBuilder(True) 340 fb.setupNameTable(nameStrings, mac=False) 341 342 assert all(n for n in fb.font["name"].names if n.platformID == 3) 343 assert not any(n for n in fb.font["name"].names if n.platformID == 1) 344 345 346def test_setupNameTable_no_windows(): 347 fb, _, nameStrings = _setupFontBuilder(True) 348 fb.setupNameTable(nameStrings, windows=False) 349 350 assert all(n for n in fb.font["name"].names if n.platformID == 1) 351 assert not any(n for n in fb.font["name"].names if n.platformID == 3) 352 353 354@pytest.mark.parametrize( 355 "is_ttf, keep_glyph_names, make_cff2, post_format", 356 [ 357 (True, True, False, 2), # TTF with post table format 2.0 358 (True, False, False, 3), # TTF with post table format 3.0 359 (False, True, False, 3), # CFF with post table format 3.0 360 (False, False, False, 3), # CFF with post table format 3.0 361 (False, True, True, 2), # CFF2 with post table format 2.0 362 (False, False, True, 3), # CFF2 with post table format 3.0 363 ], 364) 365def test_setupPost(is_ttf, keep_glyph_names, make_cff2, post_format): 366 fb, _, nameStrings = _setupFontBuilder(is_ttf) 367 368 if make_cff2: 369 fb.setupNameTable(nameStrings) 370 fb = _setupFontBuilderCFF2(_setupFontBuilderFvar(fb)) 371 372 if keep_glyph_names: 373 fb.setupPost() 374 else: 375 fb.setupPost(keepGlyphNames=keep_glyph_names) 376 377 assert fb.isTTF is is_ttf 378 assert ("CFF2" in fb.font) is make_cff2 379 assert fb.font["post"].formatType == post_format 380 381 382def test_unicodeVariationSequences(tmpdir): 383 familyName = "UVSTestFont" 384 styleName = "Regular" 385 nameStrings = dict(familyName=familyName, styleName=styleName) 386 nameStrings["psName"] = familyName + "-" + styleName 387 glyphOrder = [".notdef", "space", "zero", "zero.slash"] 388 cmap = {ord(" "): "space", ord("0"): "zero"} 389 uvs = [ 390 (0x0030, 0xFE00, "zero.slash"), 391 (0x0030, 0xFE01, None), # not an official sequence, just testing 392 ] 393 metrics = {gn: (600, 0) for gn in glyphOrder} 394 pen = TTGlyphPen(None) 395 glyph = pen.glyph() # empty placeholder 396 glyphs = {gn: glyph for gn in glyphOrder} 397 398 fb = FontBuilder(1024, isTTF=True) 399 fb.setupGlyphOrder(glyphOrder) 400 fb.setupCharacterMap(cmap, uvs) 401 fb.setupGlyf(glyphs) 402 fb.setupHorizontalMetrics(metrics) 403 fb.setupHorizontalHeader(ascent=824, descent=200) 404 fb.setupNameTable(nameStrings) 405 fb.setupOS2() 406 fb.setupPost() 407 408 outPath = os.path.join(str(tmpdir), "test_uvs.ttf") 409 fb.save(outPath) 410 _verifyOutput(outPath, tables=["cmap"]) 411 412 uvs = [ 413 (0x0030, 0xFE00, "zero.slash"), 414 ( 415 0x0030, 416 0xFE01, 417 "zero", 418 ), # should result in the exact same subtable data, due to cmap[0x0030] == "zero" 419 ] 420 fb.setupCharacterMap(cmap, uvs) 421 fb.save(outPath) 422 _verifyOutput(outPath, tables=["cmap"]) 423 424 425def test_setupPanose(): 426 from fontTools.ttLib.tables.O_S_2f_2 import Panose 427 428 fb, advanceWidths, nameStrings = _setupFontBuilder(True) 429 430 pen = TTGlyphPen(None) 431 drawTestGlyph(pen) 432 glyph = pen.glyph() 433 glyphs = {".notdef": glyph, "A": glyph, "a": glyph, ".null": glyph} 434 fb.setupGlyf(glyphs) 435 metrics = {} 436 glyphTable = fb.font["glyf"] 437 for gn, advanceWidth in advanceWidths.items(): 438 metrics[gn] = (advanceWidth, glyphTable[gn].xMin) 439 fb.setupHorizontalMetrics(metrics) 440 441 fb.setupHorizontalHeader(ascent=824, descent=200) 442 fb.setupNameTable(nameStrings) 443 fb.setupOS2() 444 fb.setupPost() 445 446 panoseValues = { # sample value of Times New Roman from https://www.w3.org/Printing/stevahn.html 447 "bFamilyType": 2, 448 "bSerifStyle": 2, 449 "bWeight": 6, 450 "bProportion": 3, 451 "bContrast": 5, 452 "bStrokeVariation": 4, 453 "bArmStyle": 5, 454 "bLetterForm": 2, 455 "bMidline": 3, 456 "bXHeight": 4, 457 } 458 panoseObj = Panose(**panoseValues) 459 460 for name in panoseValues: 461 assert getattr(fb.font["OS/2"].panose, name) == 0 462 463 fb.setupOS2(panose=panoseObj) 464 fb.setupPost() 465 466 for name, value in panoseValues.items(): 467 assert getattr(fb.font["OS/2"].panose, name) == value 468