summaryrefslogtreecommitdiff
path: root/misc/tools/bake-vf.py
blob: 328986a63b6a1cb809df65e721fa9ba48c28d541 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
"""
This script "bakes" the final Inter variable fonts.

This script performs the following:
  1. Renames the family to "Inter Variable"
  2. Updates style names to scrub away "Display"
  3. Builds a STAT table

How to debug/develop this script:

1. create a working dir and build the initial fonts:

  mkdir -p build/bake
  make -j var

2. after making changes, run script and inspect with ttx:

  ( for a in build/fonts/var/.Inter-*.var.ttf; do
      python misc/tools/bake-vf.py "$a" -o build/bake/"$(basename "${a/.Inter/Inter}")"
    done && ttx -t STAT -i -f -s build/bake/Inter-*.ttf )

"""
import sys, os, os.path, re, argparse
from fontTools.ttLib import TTFont
from fontTools.otlLib.builder import buildStatTable


# STAT_AXES is used for making a STAT table with format 4 records
STAT_AXES = [
  { "name": "Optical Size", "tag": "opsz" },
  { "name": "Weight",       "tag": "wght" },
  { "name": "Italic",       "tag": "ital" }
]


# stat_locations is used for making a STAT table with format 4 records
def stat_locations(is_italic):
  # see https://learn.microsoft.com/en-us/typography/opentype/spec/
  #     stat#axis-value-table-format-4
  ital = 1 if is_italic else 0
  suffix = " Italic" if is_italic else ""
  return [
    { "name": "Thin"+suffix,       "location":{"wght":100, "ital":ital} },
    { "name": "ExtraLight"+suffix, "location":{"wght":200, "ital":ital} },
    { "name": "Light"+suffix,      "location":{"wght":300, "ital":ital} },
    { "name": "Regular"+suffix,    "location":{"wght":400, "ital":ital}, "flags":0x2 },
    { "name": "Medium"+suffix,     "location":{"wght":500, "ital":ital} },
    { "name": "SemiBold"+suffix,   "location":{"wght":580, "ital":ital} },
    { "name": "Bold"+suffix,       "location":{"wght":660, "ital":ital} },
    { "name": "ExtraBold"+suffix,  "location":{"wght":780, "ital":ital} },
    { "name": "Black"+suffix,      "location":{"wght":900, "ital":ital} },
  ]


# stat_axes is used for making a STAT table with format 1 & 3 records
def stat_axes(is_italic):
  # see https://learn.microsoft.com/en-us/typography/opentype/spec/
  #     stat#axis-value-table-format-3
  suffix = " Italic" if is_italic else ""
  return [
    { "name": "Optical Size", "tag": "opsz" },
    { "name": "Weight", "tag": "wght", "values": [
      { "name": "Thin"+suffix,       "value": 100, "linkedValue": 400 },
      { "name": "ExtraLight"+suffix, "value": 200, "linkedValue": 500 },
      { "name": "Light"+suffix,      "value": 300, "linkedValue": 580 },
      { "name": "Regular"+suffix,    "value": 400, "linkedValue": 660, "flags":0x2 },
      { "name": "Medium"+suffix,     "value": 500, "linkedValue": 780 },
      { "name": "SemiBold"+suffix,   "value": 580, "linkedValue": 900 },
      { "name": "Bold"+suffix,       "value": 660 },
      { "name": "ExtraBold"+suffix,  "value": 780 },
      { "name": "Black"+suffix,      "value": 900 },
    ]},
  ]


WINDOWS_ENGLISH_IDS = 3, 1, 0x409
MAC_ROMAN_IDS = 1, 0, 0

LEGACY_FAMILY       = 1
SUBFAMILY_NAME      = 2
TRUETYPE_UNIQUE_ID  = 3
FULL_NAME           = 4
POSTSCRIPT_NAME     = 6
PREFERRED_FAMILY    = 16
TYPO_SUBFAMILY_NAME = 17
WWS_FAMILY          = 21
VAR_PS_NAME_PREFIX  = 25


FAMILY_RELATED_IDS = set([
  LEGACY_FAMILY,
  TRUETYPE_UNIQUE_ID,
  FULL_NAME,
  POSTSCRIPT_NAME,
  PREFERRED_FAMILY,
  WWS_FAMILY,
  VAR_PS_NAME_PREFIX,
])

WHITESPACE_RE = re.compile(r'\s+')


def remove_whitespace(s):
  return WHITESPACE_RE.sub('', s)


def normalize_whitespace(s):
  return WHITESPACE_RE.sub(' ', s)


def remove_substring(s, substr):
  # examples of remove_substring(s, "Display"):
  #   "Inter Display"   => "Inter"
  #   "Display Lol"     => "Lol"
  #   "Foo Display Lol" => "Foo Lol"
  #   " Foo   Bar Lol " => "Foo Bar Lol"
  return normalize_whitespace(s.strip().replace(substr, '')).strip()


def font_is_italic(ttfont):
  """Check if the font has the word "Italic" in its stylename"""
  stylename = ttfont["name"].getName(2, 3, 1, 0x409).toUnicode()
  return True if "Italic" in stylename else False


def set_full_name(font, fullName, fullNamePs):
  nameTable = font["name"]
  nameTable.setName(fullName, FULL_NAME, 1, 0, 0)     # mac
  nameTable.setName(fullName, FULL_NAME, 3, 1, 0x409) # windows
  nameTable.setName(fullNamePs, POSTSCRIPT_NAME, 1, 0, 0)     # mac
  nameTable.setName(fullNamePs, POSTSCRIPT_NAME, 3, 1, 0x409) # windows


def getFamilyName(font):
  nameTable = font["name"]
  r = None
  for plat_id, enc_id, lang_id in (WINDOWS_ENGLISH_IDS, MAC_ROMAN_IDS):
    for name_id in (PREFERRED_FAMILY, LEGACY_FAMILY):
      r = nameTable.getName(nameID=name_id, platformID=plat_id, platEncID=enc_id, langID=lang_id)
      if r is not None:
        break
    if r is not None:
      break
  if not r:
    raise ValueError("family name not found")
  return r.toUnicode()


def getFamilyNames(font):
  nameTable = font["name"]
  r = None
  names = dict()  # dict in Py >=3.7 maintains insertion order
  for plat_id, enc_id, lang_id in (WINDOWS_ENGLISH_IDS, MAC_ROMAN_IDS):
    for name_id in (PREFERRED_FAMILY, LEGACY_FAMILY):
      r = nameTable.getName(
        nameID=name_id, platformID=plat_id, platEncID=enc_id, langID=lang_id)
      if r:
        names[r.toUnicode()] = True
  if len(names) == 0:
    raise ValueError("family name not found")
  names = list(names.keys())
  names.sort()
  names.reverse() # longest first
  return names


def getStyleName(font):
  nameTable = font["name"]
  for plat_id, enc_id, lang_id in (WINDOWS_ENGLISH_IDS, MAC_ROMAN_IDS):
    for name_id in (TYPO_SUBFAMILY_NAME, SUBFAMILY_NAME):
      r = nameTable.getName(
        nameID=name_id, platformID=plat_id, platEncID=enc_id, langID=lang_id)
      if r is not None:
        return r.toUnicode()
  raise ValueError("style name not found")


def setStyleName(font, newStyleName):
  newFullName = getFamilyName(font).strip()
  if newStyleName != 'Regular':
    newFullName += " " + newStyleName
  newFullNamePs = remove_whitespace(newFullName)
  set_full_name(font, newFullName, newFullNamePs)

  nameTable = font["name"]
  for rec in nameTable.names:
    rid = rec.nameID
    if rid in (SUBFAMILY_NAME, TYPO_SUBFAMILY_NAME):
      rec.string = newStyleName


def setFamilyName(font, nextFamilyName):
  prevFamilyNames = getFamilyNames(font)
  # if prevFamilyNames[0] == nextFamilyName:
  #   return
  #   # raise Exception("identical family name")

  def renameRecord(nameRecord, prevFamilyNames, nextFamilyName):
    # replaces prevFamilyNames with nextFamilyName in nameRecord
    s = nameRecord.toUnicode()
    for prevFamilyName in prevFamilyNames:
      start = s.find(prevFamilyName)
      if start == -1:
        continue
      end = start + len(prevFamilyName)
      nextFamilyName = s[:start] + nextFamilyName + s[end:]
      nameRecord.string = nextFamilyName
      break
    return s, nextFamilyName

  # postcript name can't contain spaces
  psPrevFamilyNames = []
  for s in prevFamilyNames:
    s = s.strip()
    if s.find(' ') == -1:
      psPrevFamilyNames.append(s)
    else:
      # Foo Bar Baz -> FooBarBaz
      psPrevFamilyNames.append(s.replace(" ", ""))
      # # Foo Bar Baz -> FooBar-Baz
      p = s.rfind(' ')
      s = s[:p] + '-' + s[p+1:]
      psPrevFamilyNames.append(s)

  psNextFamilyName = nextFamilyName.replace(" ", "")
  found_VAR_PS_NAME_PREFIX = False
  nameTable = font["name"]

  for rec in nameTable.names:
    name_id = rec.nameID
    if name_id not in FAMILY_RELATED_IDS:
      # leave uninteresting records unmodified
      continue
    if name_id == POSTSCRIPT_NAME:
      old, new = renameRecord(rec, psPrevFamilyNames, psNextFamilyName)
    elif name_id == TRUETYPE_UNIQUE_ID:
      # The Truetype Unique ID rec may contain either the PostScript Name
      # or the Full Name
      prev_psname = None
      for s in psPrevFamilyNames:
        if s in rec.toUnicode():
          prev_psname = s
          break
      if prev_psname is not None:
        # Note: This is flawed -- a font called "Foo" renamed to "Bar Lol";
        # if this record is not a PS record, it will incorrectly be rename "BarLol".
        # However, in practice this is not a big deal since it's just an ID.
        old, new = renameRecord(rec, [prev_psname], psNextFamilyName)
      else:
        old, new = renameRecord(rec, prevFamilyNames, nextFamilyName)
    elif name_id == VAR_PS_NAME_PREFIX:
      # Variations PostScript Name Prefix.
      # If present in a variable font, it may be used as the family prefix in the
      # PostScript Name Generation for Variation Fonts algorithm.
      # The character set is restricted to ASCII-range uppercase Latin letters,
      # lowercase Latin letters, and digits.
      found_VAR_PS_NAME_PREFIX = True
      old, new = renameRecord(rec, prevFamilyNames, nextFamilyName)
    else:
      old, new = renameRecord(rec, prevFamilyNames, nextFamilyName)
    # print("  %r: '%s' -> '%s'" % (rec, old, new))

  # add name ID 25 "Variations PostScript Name Prefix" if not found
  if not found_VAR_PS_NAME_PREFIX and nextFamilyName.find('Variable') != -1:
    varPSNamePrefix = remove_whitespace(nextFamilyName)
    if font_is_italic(font):
      varPSNamePrefix += 'Italic'
    nameTable.setName(varPSNamePrefix, VAR_PS_NAME_PREFIX, 1, 0, 0)     # mac
    nameTable.setName(varPSNamePrefix, VAR_PS_NAME_PREFIX, 3, 1, 0x409) # windows


def gen_stat(ttfont):
  # builds a STAT table
  # https://learn.microsoft.com/en-us/typography/opentype/spec/stat
  #
  # We are limited to format 2 or 3 records, else Adobe products like InDesign
  # bugs out. See https://github.com/rsms/inter/issues/577
  #
  # build a version 1.1 STAT table with format 1 and 3 records:
  buildStatTable(ttfont, stat_axes(font_is_italic(ttfont)))
  #
  # build a version 1.2 STAT table with format 4 records:
  # locations = stat_locations(font_is_italic(ttfont))
  # buildStatTable(ttfont, STAT_AXES, locations=locations)


# def fixup_fvar(ttfont):
#   fvar = ttfont['fvar']
#   for a in fvar.axes:
#     if a.axisTag == "wght":
#       a.defaultValue = 400
#       break


# def fixup_os2(ttfont):
#   os2 = ttfont['OS/2']
#   os2.usWeightClass = 400


def main():
  argparser = argparse.ArgumentParser(
    description='Generate STAT table for variable font family')
  a = lambda *args, **kwargs: argparser.add_argument(*args, **kwargs)
  a('--family', metavar='<name>',
    help='Rename family to <name> instead of "Inter Variable"')
  a('-o', '--output', metavar='<file>',
    help='Output font file. Defaults to input file (overwrite)')
  a('input', metavar='<file>', help='Input font file')

  args = argparser.parse_args()

  # load font
  font = TTFont(args.input, recalcBBoxes=False, recalcTimestamp=False)

  # set family name
  if not args.family:
    args.family = "Inter Variable"
  setFamilyName(font, args.family)

  # set style name
  stylename = remove_substring(getStyleName(font), "Display")
  if stylename == '':
    stylename = 'Regular'
  setStyleName(font, stylename)

  # build STAT table
  gen_stat(font)

  # # fixup fvar table (set default wght value)
  # fixup_fvar(font)

  # # fixup OS/2 table (set usWeightClass)
  # fixup_os2(font)

  # save font
  outfile = args.output or args.input
  font.save(outfile)


if __name__ == '__main__':
  main()