__init__.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571
  1. from copy import deepcopy
  2. from fontTools.ttLib import TTFont
  3. from fontTools.misc.testTools import getXML
  4. from fontTools.otlLib.builder import buildStatTable
  5. from fontTools.varLib.instancer.names import _updateUniqueIdNameRecord, NameID
  6. from fontTools.ttLib.tables._f_v_a_r import NamedInstance
  7. from pkg_resources import resource_filename
  8. from google.protobuf import text_format
  9. from collections import OrderedDict
  10. from axisregistry.axes_pb2 import AxisProto
  11. from collections import defaultdict
  12. from itertools import chain
  13. import logging
  14. from glob import glob
  15. import os
  16. try:
  17. from ._version import version as __version__ # type: ignore
  18. except ImportError:
  19. __version__ = "0.0.0+unknown"
  20. log = logging.getLogger(__file__)
  21. # TODO we may have more of these. Please note that some applications may not
  22. # implement variable font style linking.
  23. LINKED_VALUES = {
  24. "wght": {400.0: 700.0},
  25. "ital": {0.0: 1.0},
  26. }
  27. # Static font styles. The GF api only support the following static font styles
  28. GF_STATIC_STYLES = OrderedDict(
  29. [
  30. ("Thin", 100),
  31. ("ExtraLight", 200),
  32. ("Light", 300),
  33. ("Regular", 400),
  34. ("Medium", 500),
  35. ("SemiBold", 600),
  36. ("Bold", 700),
  37. ("ExtraBold", 800),
  38. ("Black", 900),
  39. ("Thin Italic", 100),
  40. ("ExtraLight Italic", 200),
  41. ("Light Italic", 300),
  42. ("Italic", 400),
  43. ("Medium Italic", 500),
  44. ("SemiBold Italic", 600),
  45. ("Bold Italic", 700),
  46. ("ExtraBold Italic", 800),
  47. ("Black Italic", 900),
  48. ]
  49. )
  50. def load_protobuf(klass, path):
  51. message = klass()
  52. with open(path, "rb") as text_data:
  53. text_format.Merge(text_data.read(), message)
  54. return message
  55. class AxisRegistry:
  56. def __init__(self, fp=resource_filename("axisregistry", "data")):
  57. axis_fps = [fp for fp in glob(os.path.join(fp, "*.textproto"))]
  58. self._data = {}
  59. for fp in axis_fps:
  60. axis = load_protobuf(AxisProto, fp)
  61. self._data[axis.tag] = axis
  62. def __getitem__(self, k):
  63. return self._data[k]
  64. def __iter__(self):
  65. for i in self._data.keys():
  66. yield i
  67. def keys(self):
  68. return self._data.keys()
  69. def items(self):
  70. return self._data.items()
  71. def get_fallback(self, name):
  72. for a in self:
  73. for fallback in self[a].fallback:
  74. if name == fallback.name:
  75. return a, fallback
  76. return None, None
  77. def fallbacks_in_fvar(self, ttFont):
  78. res = defaultdict(list)
  79. axes_in_font = {
  80. a.axisTag: {"min": a.minValue, "max": a.maxValue}
  81. for a in ttFont["fvar"].axes
  82. }
  83. for axis in axes_in_font:
  84. if axis not in self.keys():
  85. log.warn(f"Axis {axis} not found in GF Axis Registry!")
  86. continue
  87. for fallback in self[axis].fallback:
  88. if (
  89. fallback.value < axes_in_font[axis]["min"]
  90. or fallback.value > axes_in_font[axis]["max"]
  91. ):
  92. continue
  93. res[axis].append(fallback)
  94. return res
  95. def fallbacks_in_name_table(self, ttFont):
  96. res = []
  97. name_table = ttFont["name"]
  98. tokens = (
  99. name_table.getBestFamilyName().split()[1:]
  100. + name_table.getBestSubFamilyName().split()
  101. )
  102. fvar_axes_in_font = [a.axisTag for a in ttFont["fvar"].axes]
  103. for token in tokens:
  104. axis, fallback = axis_registry.get_fallback(token)
  105. if any([not axis, axis in fvar_axes_in_font, fallback in res]):
  106. continue
  107. res.append((axis, fallback))
  108. return res
  109. def fallback_for_value(self, axis_tag, value):
  110. if axis_tag in axis_registry:
  111. return next(
  112. (f for f in axis_registry[axis_tag].fallback if f.value == value),
  113. None,
  114. )
  115. return None
  116. axis_registry = AxisRegistry()
  117. # sort user axes by alphabetical order and append presorted registered axes
  118. AXIS_ORDER = sorted([i for i in axis_registry if i.isupper()]) + [
  119. "opsz",
  120. "wdth",
  121. "wght",
  122. "ital",
  123. "slnt",
  124. ]
  125. def is_variable(ttFont):
  126. return "fvar" in ttFont
  127. def _fvar_dflts(ttFont):
  128. res = OrderedDict()
  129. for a in ttFont["fvar"].axes:
  130. fallback = axis_registry.fallback_for_value(a.axisTag, a.defaultValue)
  131. if a.axisTag == "opsz":
  132. name = f"{int(a.defaultValue)}pt"
  133. elided = True
  134. elif fallback:
  135. name = fallback.name
  136. elided = fallback.value == axis_registry[
  137. a.axisTag
  138. ].default_value and name not in ["Regular", "Italic", "14pt"]
  139. else:
  140. name = None
  141. elided = True # since we can't find a name for it, keep it elided
  142. res[a.axisTag] = {"value": a.defaultValue, "name": name, "elided": elided}
  143. return res
  144. def build_stat(ttFont, sibling_ttFonts=[]):
  145. log.info("Building STAT table")
  146. assert is_variable(ttFont), "not a VF!"
  147. fallbacks_in_fvar = axis_registry.fallbacks_in_fvar(ttFont)
  148. fallbacks_in_siblings = list(
  149. chain.from_iterable(
  150. axis_registry.fallbacks_in_name_table(f) for f in sibling_ttFonts
  151. )
  152. )
  153. fallbacks_in_names = axis_registry.fallbacks_in_name_table(ttFont)
  154. nametable = ttFont["name"]
  155. fvar = ttFont["fvar"]
  156. # rm old STAT table and associated name table records
  157. fvar_instance_nameids = set(i.subfamilyNameID for i in fvar.instances)
  158. fvar_axis_nameids = set(a.axisNameID for a in fvar.axes)
  159. fvar_nameids = fvar_axis_nameids | fvar_instance_nameids
  160. # These NameIDs are required for applications to work correctly so
  161. # they cannot be deleted.
  162. # https://learn.microsoft.com/en-us/typography/opentype/spec/name
  163. keep_nameids = set(range(26)) | fvar_nameids
  164. if "STAT" in ttFont:
  165. stat = ttFont["STAT"]
  166. if stat.table.AxisValueCount > 0:
  167. axis_values = stat.table.AxisValueArray.AxisValue
  168. for ax in axis_values:
  169. if ax.ValueNameID not in keep_nameids:
  170. nametable.removeNames(nameID=ax.ValueNameID)
  171. if stat.table.DesignAxisCount > 0:
  172. axes = stat.table.DesignAxisRecord.Axis
  173. for ax in axes:
  174. if ax.AxisNameID not in keep_nameids:
  175. nametable.removeNames(nameID=ax.AxisNameID)
  176. del ttFont["STAT"]
  177. res = []
  178. # use fontTools build_stat. Link contains function params and usage example
  179. # https://github.com/fonttools/fonttools/blob/a293606fc8c88af8510d0688a6a36271ff4ff350/Lib/fontTools/otlLib/builder.py#L2683
  180. seen_axes = set()
  181. for axis, fallbacks in fallbacks_in_fvar.items():
  182. seen_axes.add(axis)
  183. a = {"tag": axis, "name": axis_registry[axis].display_name, "values": []}
  184. for fallback in fallbacks:
  185. a["values"].append(
  186. {
  187. "name": fallback.name,
  188. "value": fallback.value,
  189. # include flags and linked values
  190. "flags": (
  191. 0x2
  192. if fallback.value == axis_registry[axis].default_value
  193. else 0x0
  194. ),
  195. }
  196. )
  197. if axis in LINKED_VALUES and fallback.value in LINKED_VALUES[axis]:
  198. linked_value = LINKED_VALUES[axis][fallback.value]
  199. if any(f.value == linked_value for f in fallbacks):
  200. a["values"][-1]["linkedValue"] = linked_value
  201. res.append(a)
  202. for axis, fallback in fallbacks_in_names:
  203. if axis in seen_axes:
  204. continue
  205. a = {
  206. "tag": axis,
  207. "name": axis_registry[axis].display_name,
  208. "values": [{"name": fallback.name, "value": fallback.value, "flags": 0x0}],
  209. }
  210. if axis in LINKED_VALUES and fallback.value in LINKED_VALUES[axis]:
  211. linked_value = LINKED_VALUES[axis][fallback.value]
  212. a["values"][0]["linkedValue"] = linked_value
  213. res.append(a)
  214. for axis, fallback in fallbacks_in_siblings:
  215. if axis in seen_axes:
  216. continue
  217. elided_value = axis_registry[axis].default_value
  218. elided_fallback = axis_registry.fallback_for_value(axis, elided_value)
  219. a = {
  220. "tag": axis,
  221. "name": axis_registry[axis].display_name,
  222. "values": [
  223. {"name": elided_fallback.name, "value": elided_value, "flags": 0x2}
  224. ],
  225. }
  226. if axis in LINKED_VALUES and elided_value in LINKED_VALUES[axis]:
  227. a["values"][0]["linkedValue"] = LINKED_VALUES[axis][elided_value]
  228. res.append(a)
  229. buildStatTable(ttFont, res, macNames=False)
  230. def build_name_table(ttFont, family_name=None, style_name=None, siblings=[]):
  231. from fontTools.varLib.instancer import setRibbiBits
  232. log.info("Building name table")
  233. name_table = ttFont["name"]
  234. family_name = family_name if family_name else name_table.getBestFamilyName()
  235. style_name = style_name if style_name else name_table.getBestSubFamilyName()
  236. if is_variable(ttFont):
  237. build_vf_name_table(ttFont, family_name, siblings=siblings)
  238. else:
  239. build_static_name_table_v1(ttFont, family_name, style_name)
  240. # Set bits
  241. style_name = name_table.getBestSubFamilyName()
  242. # usWeightClass
  243. weight_seen = False
  244. for weight in sorted(GF_STATIC_STYLES, key=lambda k: len(k), reverse=True):
  245. if weight in style_name:
  246. weight_seen = True
  247. ttFont["OS/2"].usWeightClass = GF_STATIC_STYLES[weight]
  248. break
  249. if not weight_seen:
  250. log.warning(
  251. f"No known weight found for stylename {style_name}. Cannot set OS2.usWeightClass"
  252. )
  253. setRibbiBits(ttFont)
  254. def _fvar_instance_collisions(ttFont, siblings=[]):
  255. """Check if a font family is going to have colliding fvar instances.
  256. Collision occur when a family has has 2+ roman styles or 2+ italic
  257. styles."""
  258. def is_italic(font):
  259. return font["post"].italicAngle != 0.0
  260. family_styles = [is_italic(f) for f in siblings + [ttFont]]
  261. return len(family_styles) != len(set(family_styles))
  262. def build_vf_name_table(ttFont, family_name, siblings=[]):
  263. # VF name table should reflect the 0 origin of the font!
  264. assert is_variable(ttFont), "Not a VF!"
  265. style_name = _vf_style_name(ttFont, family_name)
  266. if _fvar_instance_collisions(ttFont, siblings):
  267. build_static_name_table_v1(ttFont, family_name, style_name)
  268. else:
  269. build_static_name_table(ttFont, family_name, style_name)
  270. build_variations_ps_name(ttFont, family_name)
  271. def build_variations_ps_name(ttFont, family_name=None):
  272. assert is_variable(ttFont), "Not a VF!"
  273. if not family_name:
  274. family_name = ttFont["name"].getBestFamilyName()
  275. font_styles = axis_registry.fallbacks_in_name_table(ttFont)
  276. if font_styles:
  277. vf_ps = family_name.replace(" ", "") + "".join(
  278. [
  279. fallback.name
  280. for _, fallback in font_styles
  281. if fallback.name not in family_name
  282. ]
  283. )
  284. else:
  285. vf_ps = family_name.replace(" ", "")
  286. ttFont["name"].setName(vf_ps, NameID.VARIATIONS_POSTSCRIPT_NAME_PREFIX, 3, 1, 0x409)
  287. def _vf_style_name(ttFont, family_name):
  288. fvar_dflts = _fvar_dflts(ttFont)
  289. res = []
  290. for axis_name in AXIS_ORDER:
  291. if axis_name not in fvar_dflts:
  292. continue
  293. value = fvar_dflts[axis_name]
  294. if not value["elided"]:
  295. res.append(value["name"])
  296. family_name_tokens = family_name.split()
  297. font_styles = axis_registry.fallbacks_in_name_table(ttFont)
  298. for _, fallback in font_styles:
  299. if fallback.name not in res and fallback.name not in family_name_tokens:
  300. res.append(fallback.name)
  301. name = " ".join(res).replace("Regular Italic", "Italic")
  302. log.debug(f"Built VF style name: '{name}'")
  303. return name
  304. def build_fvar_instances(ttFont, axis_dflts={}):
  305. """Replace a variable font's fvar instances with a set of new instances
  306. which conform to the Google Fonts instance spec:
  307. https://github.com/googlefonts/gf-docs/tree/main/Spec#fvar-instances
  308. """
  309. assert is_variable(ttFont), "Not a VF!"
  310. log.info("Building fvar instances")
  311. fvar = ttFont["fvar"]
  312. name_table = ttFont["name"]
  313. style_name = name_table.getBestSubFamilyName()
  314. # Protect name IDs which are shared with the STAT table
  315. stat_nameids = []
  316. if "STAT" in ttFont:
  317. if ttFont["STAT"].table.AxisValueCount > 0:
  318. stat_nameids.extend(
  319. av.ValueNameID for av in ttFont["STAT"].table.AxisValueArray.AxisValue
  320. )
  321. if ttFont["STAT"].table.DesignAxisCount > 0:
  322. stat_nameids.extend(
  323. av.AxisNameID for av in ttFont["STAT"].table.DesignAxisRecord.Axis
  324. )
  325. # rm old fvar subfamily and ps name records
  326. for inst in fvar.instances:
  327. if inst.subfamilyNameID not in [2, 17] + stat_nameids:
  328. name_table.removeNames(nameID=inst.subfamilyNameID)
  329. if inst.postscriptNameID not in [65535, 6]:
  330. name_table.removeNames(nameID=inst.postscriptNameID)
  331. fvar_dflts = _fvar_dflts(ttFont)
  332. if not axis_dflts:
  333. axis_dflts = {k: v["value"] for k, v in fvar_dflts.items()}
  334. is_italic = "Italic" in style_name
  335. is_roman_and_italic = any(a for a in ("slnt", "ital") if a in fvar_dflts)
  336. fallbacks = axis_registry.fallbacks_in_fvar(ttFont)
  337. # some families may not have a wght axis e.g
  338. # https://fonts.google.com/specimen/League+Gothic
  339. # these families just have a single weight which is Regular
  340. if "wght" not in fvar_dflts:
  341. fallback = next(
  342. (f for f in axis_registry["wght"].fallback if f.value == 400.0), None
  343. )
  344. fallbacks["wght"] = [fallback]
  345. wght_fallbacks = fallbacks["wght"]
  346. ital_axis = next((a for a in fvar.axes if a.axisTag == "ital"), None)
  347. slnt_axis = next((a for a in fvar.axes if a.axisTag == "slnt"), None)
  348. def gen_instances(is_italic):
  349. results = []
  350. for fallback in wght_fallbacks:
  351. name = fallback.name if not is_italic else f"{fallback.name} Italic".strip()
  352. name = name.replace("Regular Italic", "Italic")
  353. coordinates = {k: v for k, v in axis_dflts.items()}
  354. if "wght" in fvar_dflts:
  355. coordinates["wght"] = fallback.value
  356. if is_italic:
  357. if ital_axis:
  358. coordinates["ital"] = ital_axis.minValue
  359. elif slnt_axis:
  360. coordinates["slnt"] = slnt_axis.minValue
  361. inst = NamedInstance()
  362. inst.subfamilyNameID = name_table.addName(name)
  363. inst.coordinates = coordinates
  364. log.debug(f"Adding fvar instance: {name}: {coordinates}")
  365. results.append(inst)
  366. return results
  367. instances = []
  368. if is_roman_and_italic:
  369. for bool_ in (False, True):
  370. instances += gen_instances(is_italic=bool_)
  371. elif is_italic:
  372. instances += gen_instances(is_italic=True)
  373. else:
  374. instances += gen_instances(is_italic=False)
  375. fvar.instances = instances
  376. def build_static_name_table(ttFont, family_name, style_name):
  377. # stip mac names
  378. name_table = ttFont["name"]
  379. name_table.removeNames(platformID=1)
  380. existing_name = ttFont["name"].getBestFamilyName()
  381. removed_names = {}
  382. names = {}
  383. is_ribbi = (
  384. True if style_name in ("Regular", "Italic", "Bold", "Bold Italic") else False
  385. )
  386. if is_ribbi:
  387. full_name = f"{family_name} {style_name}"
  388. ps_name = f"{family_name}-{style_name}".replace(" ", "")
  389. names[(NameID.FAMILY_NAME, 3, 1, 0x409)] = family_name
  390. names[(NameID.SUBFAMILY_NAME, 3, 1, 0x409)] = style_name
  391. names[(NameID.FULL_FONT_NAME, 3, 1, 0x409)] = full_name
  392. names[(NameID.POSTSCRIPT_NAME, 3, 1, 0x409)] = ps_name
  393. for name_id in (
  394. NameID.TYPOGRAPHIC_FAMILY_NAME,
  395. NameID.TYPOGRAPHIC_SUBFAMILY_NAME,
  396. 21,
  397. 22,
  398. ):
  399. removed_names[name_id] = name_table.getDebugName(name_id)
  400. name_table.removeNames(nameID=name_id)
  401. else:
  402. style_tokens = style_name.split()
  403. new_family_name = family_name.split()
  404. is_italic = "Italic" in style_tokens
  405. for t in style_tokens:
  406. if t in ["Regular", "Italic"] or t in new_family_name:
  407. continue
  408. new_family_name.append(t)
  409. new_family_name = " ".join(new_family_name)
  410. new_style_name = "Italic" if is_italic else "Regular"
  411. full_name = f"{family_name} {style_name}"
  412. ps_name = f"{family_name}-{style_name}".replace(" ", "")
  413. names[(NameID.FAMILY_NAME, 3, 1, 0x409)] = new_family_name
  414. names[(NameID.SUBFAMILY_NAME, 3, 1, 0x409)] = new_style_name
  415. names[(NameID.FULL_FONT_NAME, 3, 1, 0x409)] = full_name
  416. names[(NameID.POSTSCRIPT_NAME, 3, 1, 0x409)] = ps_name
  417. names[(NameID.TYPOGRAPHIC_FAMILY_NAME, 3, 1, 0x409)] = family_name
  418. names[(NameID.TYPOGRAPHIC_SUBFAMILY_NAME, 3, 1, 0x409)] = style_name
  419. # we do not use WWS names since we use the RIBBI naming schema
  420. for name_id in (21, 22):
  421. removed_names[name_id] = name_table.getDebugName(name_id)
  422. name_table.removeNames(nameID=name_id)
  423. # If STAT table was using any removed names, add then back with a new ID
  424. if "STAT" in ttFont and removed_names:
  425. if ttFont["STAT"].table.AxisValueArray:
  426. for av in ttFont["STAT"].table.AxisValueArray.AxisValue:
  427. if av.ValueNameID in removed_names:
  428. av.ValueNameID = name_table.addMultilingualName(
  429. {"en": removed_names[av.ValueNameID]}
  430. )
  431. for av in ttFont["STAT"].table.DesignAxisRecord.Axis:
  432. if av.AxisNameID in removed_names:
  433. av.AxisNameID = name_table.addMultilingualName(
  434. {"en": removed_names[av.AxisNameID]}
  435. )
  436. names[(NameID.UNIQUE_FONT_IDENTIFIER, 3, 1, 0x409)] = _updateUniqueIdNameRecord(
  437. ttFont, {k[0]: v for k, v in names.items()}, (3, 1, 0x409)
  438. )
  439. for k, v in names.items():
  440. log.debug(f"Adding name record {k}: {v}")
  441. name_table.setName(v, *k)
  442. # Replace occurences of old family name in untouched records
  443. skip_ids = [i.numerator for i in NameID]
  444. for r in ttFont["name"].names:
  445. if r.nameID in skip_ids:
  446. continue
  447. current = r.toUnicode()
  448. if existing_name not in current:
  449. continue
  450. if " " not in current:
  451. replacement = current.replace(existing_name, family_name).replace(" ", "")
  452. else:
  453. replacement = current.replace(existing_name, family_name)
  454. ttFont["name"].setName(
  455. replacement, r.nameID, r.platformID, r.platEncID, r.langID
  456. )
  457. def build_static_name_table_v1(ttFont, family_name, style_name):
  458. """Pre VF name tables, this version can only accept wght + ital"""
  459. non_weight_tokens = []
  460. v1_tokens = []
  461. tokens = style_name.split()
  462. for t in tokens:
  463. if t not in GF_STATIC_STYLES:
  464. non_weight_tokens.append(t)
  465. else:
  466. v1_tokens.append(t)
  467. family_tokens = family_name.split()
  468. new_family_name = []
  469. for t in family_tokens:
  470. if t in non_weight_tokens or t in new_family_name:
  471. continue
  472. new_family_name.append(t)
  473. for t in non_weight_tokens:
  474. new_family_name.append(t)
  475. family_name = " ".join(new_family_name)
  476. style_name = " ".join(v1_tokens).replace("Regular Italic", "Italic").strip()
  477. style_name = style_name or "Regular"
  478. log.debug(f"New family name: {family_name}")
  479. log.debug(f"New style name: {style_name}")
  480. build_static_name_table(ttFont, family_name, style_name)
  481. def build_filename(ttFont):
  482. name_table = ttFont["name"]
  483. family_name = name_table.getBestFamilyName()
  484. style_name = name_table.getBestSubFamilyName()
  485. _, ext = os.path.splitext(ttFont.reader.file.name)
  486. if is_variable(ttFont):
  487. is_italic = "Italic" in style_name
  488. axes = _fvar_dflts(ttFont).keys()
  489. axes = sorted([a for a in axes if a.isupper()]) + sorted(
  490. [a for a in axes if a.islower()]
  491. )
  492. if is_italic:
  493. return f"{family_name}-Italic[{','.join(axes)}]{ext}".replace(" ", "")
  494. return f"{family_name}[{','.join(axes)}]{ext}".replace(" ", "")
  495. return f"{family_name}-{style_name}{ext}".replace(" ", "")
  496. def dump(table, ttFont=None):
  497. return "\n".join(getXML(table.toXML, ttFont))