xref: /aosp_15_r20/external/fonttools/Tests/fontBuilder/fontBuilder_test.py (revision e1fe3e4ad2793916b15cccdc4a7da52a7e1dd0e9)
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