__init__.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515
  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 fallback:
  132. name = fallback.name
  133. elided = fallback.value == axis_registry[
  134. a.axisTag
  135. ].default_value and name not in ["Regular", "Italic"]
  136. else:
  137. name = None
  138. elided = True # since we can't find a name for it, keep it elided
  139. res[a.axisTag] = {"value": a.defaultValue, "name": name, "elided": elided}
  140. return res
  141. def build_stat(ttFont, sibling_ttFonts=[]):
  142. log.info("Building STAT table")
  143. assert is_variable(ttFont), "not a VF!"
  144. fallbacks_in_fvar = axis_registry.fallbacks_in_fvar(ttFont)
  145. fallbacks_in_siblings = list(
  146. chain.from_iterable(
  147. axis_registry.fallbacks_in_name_table(f) for f in sibling_ttFonts
  148. )
  149. )
  150. fallbacks_in_names = axis_registry.fallbacks_in_name_table(ttFont)
  151. nametable = ttFont["name"]
  152. fvar = ttFont["fvar"]
  153. # rm old STAT table and associated name table records
  154. fvar_nameids = set(i.subfamilyNameID for i in fvar.instances)
  155. if "STAT" in ttFont:
  156. stat = ttFont["STAT"]
  157. if stat.table.AxisValueCount > 0:
  158. axis_values = stat.table.AxisValueArray.AxisValue
  159. for ax in axis_values:
  160. if ax.ValueNameID not in fvar_nameids:
  161. nametable.removeNames(nameID=ax.ValueNameID)
  162. if stat.table.DesignAxisCount > 0:
  163. axes = stat.table.DesignAxisRecord.Axis
  164. for ax in axes:
  165. if ax.AxisNameID not in fvar_nameids:
  166. nametable.removeNames(nameID=ax.AxisNameID)
  167. del ttFont["STAT"]
  168. res = []
  169. # use fontTools build_stat. Link contains function params and usage example
  170. # https://github.com/fonttools/fonttools/blob/a293606fc8c88af8510d0688a6a36271ff4ff350/Lib/fontTools/otlLib/builder.py#L2683
  171. seen_axes = set()
  172. for axis, fallbacks in fallbacks_in_fvar.items():
  173. seen_axes.add(axis)
  174. a = {"tag": axis, "name": axis_registry[axis].display_name, "values": []}
  175. for fallback in fallbacks:
  176. a["values"].append(
  177. {
  178. "name": fallback.name,
  179. "value": fallback.value,
  180. # include flags and linked values
  181. "flags": 0x2
  182. if fallback.value == axis_registry[axis].default_value
  183. else 0x0,
  184. }
  185. )
  186. if axis in LINKED_VALUES and fallback.value in LINKED_VALUES[axis]:
  187. a["values"][-1]["linkedValue"] = LINKED_VALUES[axis][fallback.value]
  188. res.append(a)
  189. for axis, fallback in fallbacks_in_names:
  190. if axis in seen_axes:
  191. continue
  192. a = {
  193. "tag": axis,
  194. "name": axis_registry[axis].display_name,
  195. "values": [{"name": fallback.name, "value": fallback.value, "flags": 0x0}],
  196. }
  197. if axis in LINKED_VALUES and fallback.value in LINKED_VALUES[axis]:
  198. a["values"][0]["linkedValue"] = LINKED_VALUES[axis][fallback.value]
  199. res.append(a)
  200. for axis, fallback in fallbacks_in_siblings:
  201. if axis in seen_axes:
  202. continue
  203. value = 0.0
  204. a = {
  205. "tag": axis,
  206. "name": axis_registry[axis].display_name,
  207. "values": [{"name": "Normal", "value": value, "flags": 0x2}],
  208. }
  209. if axis in LINKED_VALUES and value in LINKED_VALUES[axis]:
  210. a["values"][0]["linkedValue"] = LINKED_VALUES[axis][value]
  211. res.append(a)
  212. buildStatTable(ttFont, res, macNames=False)
  213. def build_name_table(ttFont, family_name=None, style_name=None, siblings=[]):
  214. log.info("Building name table")
  215. name_table = ttFont["name"]
  216. family_name = family_name if family_name else name_table.getBestFamilyName()
  217. style_name = style_name if style_name else name_table.getBestSubFamilyName()
  218. if is_variable(ttFont):
  219. return build_vf_name_table(ttFont, family_name, siblings=siblings)
  220. return build_static_name_table_v1(ttFont, family_name, style_name)
  221. def _fvar_instance_collisions(ttFont, siblings=[]):
  222. """Check if a font family is going to have colliding fvar instances.
  223. Collision occur when a family has has 2+ roman styles or 2+ italic
  224. styles."""
  225. def is_italic(font):
  226. return font["post"].italicAngle != 0.0
  227. family_styles = [is_italic(f) for f in siblings + [ttFont]]
  228. return len(family_styles) != len(set(family_styles))
  229. def build_vf_name_table(ttFont, family_name, siblings=[]):
  230. # VF name table should reflect the 0 origin of the font!
  231. assert is_variable(ttFont), "Not a VF!"
  232. style_name = _vf_style_name(ttFont, family_name)
  233. if _fvar_instance_collisions(ttFont, siblings):
  234. build_static_name_table_v1(ttFont, family_name, style_name)
  235. else:
  236. build_static_name_table(ttFont, family_name, style_name)
  237. build_variations_ps_name(ttFont, family_name)
  238. def build_variations_ps_name(ttFont, family_name=None):
  239. assert is_variable(ttFont), "Not a VF!"
  240. if not family_name:
  241. family_name = ttFont["name"].getBestFamilyName()
  242. font_styles = axis_registry.fallbacks_in_name_table(ttFont)
  243. if font_styles:
  244. vf_ps = family_name.replace(" ", "") + "".join(
  245. [
  246. fallback.name
  247. for _, fallback in font_styles
  248. if fallback.name not in family_name
  249. ]
  250. )
  251. else:
  252. vf_ps = family_name.replace(" ", "")
  253. ttFont["name"].setName(vf_ps, NameID.VARIATIONS_POSTSCRIPT_NAME_PREFIX, 3, 1, 0x409)
  254. def _vf_style_name(ttFont, family_name):
  255. fvar_dflts = _fvar_dflts(ttFont)
  256. res = []
  257. for axis_name in AXIS_ORDER:
  258. if axis_name not in fvar_dflts:
  259. continue
  260. value = fvar_dflts[axis_name]
  261. if not value["elided"]:
  262. res.append(value["name"])
  263. family_name_tokens = family_name.split()
  264. font_styles = axis_registry.fallbacks_in_name_table(ttFont)
  265. for _, fallback in font_styles:
  266. if fallback.name not in res and fallback.name not in family_name_tokens:
  267. res.append(fallback.name)
  268. name = " ".join(res).replace("Regular Italic", "Italic")
  269. log.debug(f"Built VF style name: '{name}'")
  270. return name
  271. def build_fvar_instances(ttFont, axis_dflts={}):
  272. """Replace a variable font's fvar instances with a set of new instances
  273. which conform to the Google Fonts instance spec:
  274. https://github.com/googlefonts/gf-docs/tree/main/Spec#fvar-instances
  275. """
  276. assert is_variable(ttFont), "Not a VF!"
  277. log.info("Building fvar instances")
  278. fvar = ttFont["fvar"]
  279. name_table = ttFont["name"]
  280. style_name = name_table.getBestSubFamilyName()
  281. # Protect name IDs which are shared with the STAT table
  282. stat_nameids = []
  283. if "STAT" in ttFont:
  284. if ttFont["STAT"].table.AxisValueCount > 0:
  285. stat_nameids = [
  286. av.ValueNameID for av in ttFont["STAT"].table.AxisValueArray.AxisValue
  287. ]
  288. # rm old fvar subfamily and ps name records
  289. for inst in fvar.instances:
  290. if inst.subfamilyNameID not in [2, 17] + stat_nameids:
  291. name_table.removeNames(nameID=inst.subfamilyNameID)
  292. if inst.postscriptNameID not in [65535, 6]:
  293. name_table.removeNames(nameID=inst.postscriptNameID)
  294. fvar_dflts = _fvar_dflts(ttFont)
  295. if not axis_dflts:
  296. axis_dflts = {k: v["value"] for k, v in fvar_dflts.items()}
  297. is_italic = "Italic" in style_name
  298. is_roman_and_italic = any(a for a in ("slnt", "ital") if a in fvar_dflts)
  299. fallbacks = axis_registry.fallbacks_in_fvar(ttFont)
  300. # some families may not have a wght axis e.g
  301. # https://fonts.google.com/specimen/League+Gothic
  302. # these families just have a single weight which is Regular
  303. if "wght" not in fvar_dflts:
  304. fallback = next(
  305. (f for f in axis_registry["wght"].fallback if f.value == 400.0), None
  306. )
  307. fallbacks["wght"] = [fallback]
  308. wght_fallbacks = fallbacks["wght"]
  309. ital_axis = next((a for a in fvar.axes if a.axisTag == "ital"), None)
  310. slnt_axis = next((a for a in fvar.axes if a.axisTag == "slnt"), None)
  311. def gen_instances(is_italic):
  312. results = []
  313. for fallback in wght_fallbacks:
  314. name = fallback.name if not is_italic else f"{fallback.name} Italic".strip()
  315. name = name.replace("Regular Italic", "Italic")
  316. coordinates = {k: v for k, v in axis_dflts.items()}
  317. if "wght" in fvar_dflts:
  318. coordinates["wght"] = fallback.value
  319. if is_italic:
  320. if ital_axis:
  321. coordinates["ital"] = ital_axis.minValue
  322. elif slnt_axis:
  323. coordinates["slnt"] = slnt_axis.minValue
  324. inst = NamedInstance()
  325. inst.subfamilyNameID = name_table.addName(name)
  326. inst.coordinates = coordinates
  327. log.debug(f"Adding fvar instance: {name}: {coordinates}")
  328. results.append(inst)
  329. return results
  330. instances = []
  331. if is_roman_and_italic:
  332. for bool_ in (False, True):
  333. instances += gen_instances(is_italic=bool_)
  334. elif is_italic:
  335. instances += gen_instances(is_italic=True)
  336. else:
  337. instances += gen_instances(is_italic=False)
  338. fvar.instances = instances
  339. def build_static_name_table(ttFont, family_name, style_name):
  340. # stip mac names
  341. name_table = ttFont["name"]
  342. name_table.removeNames(platformID=1)
  343. existing_name = ttFont["name"].getBestFamilyName()
  344. names = {}
  345. is_ribbi = (
  346. True if style_name in ("Regular", "Italic", "Bold", "Bold Italic") else False
  347. )
  348. if is_ribbi:
  349. full_name = f"{family_name} {style_name}"
  350. ps_name = f"{family_name}-{style_name}".replace(" ", "")
  351. names[(NameID.FAMILY_NAME, 3, 1, 0x409)] = family_name
  352. names[(NameID.SUBFAMILY_NAME, 3, 1, 0x409)] = style_name
  353. names[(NameID.FULL_FONT_NAME, 3, 1, 0x409)] = full_name
  354. names[(NameID.POSTSCRIPT_NAME, 3, 1, 0x409)] = ps_name
  355. for name_id in (
  356. NameID.TYPOGRAPHIC_FAMILY_NAME,
  357. NameID.TYPOGRAPHIC_SUBFAMILY_NAME,
  358. 21,
  359. 22,
  360. ):
  361. name_table.removeNames(nameID=name_id)
  362. else:
  363. style_tokens = style_name.split()
  364. new_family_name = family_name.split()
  365. is_italic = "Italic" in style_tokens
  366. for t in style_tokens:
  367. if t in ["Regular", "Italic"] or t in new_family_name:
  368. continue
  369. new_family_name.append(t)
  370. new_family_name = " ".join(new_family_name)
  371. new_style_name = "Italic" if is_italic else "Regular"
  372. full_name = f"{family_name} {style_name}"
  373. ps_name = f"{family_name}-{style_name}".replace(" ", "")
  374. names[(NameID.FAMILY_NAME, 3, 1, 0x409)] = new_family_name
  375. names[(NameID.SUBFAMILY_NAME, 3, 1, 0x409)] = new_style_name
  376. names[(NameID.FULL_FONT_NAME, 3, 1, 0x409)] = full_name
  377. names[(NameID.POSTSCRIPT_NAME, 3, 1, 0x409)] = ps_name
  378. names[(NameID.TYPOGRAPHIC_FAMILY_NAME, 3, 1, 0x409)] = family_name
  379. names[(NameID.TYPOGRAPHIC_SUBFAMILY_NAME, 3, 1, 0x409)] = style_name
  380. # we do not use WWS names since we use the RIBBI naming schema
  381. for name_id in (21, 22):
  382. name_table.removeNames(nameID=name_id)
  383. names[(NameID.UNIQUE_FONT_IDENTIFIER, 3, 1, 0x409)] = _updateUniqueIdNameRecord(
  384. ttFont, {k[0]: v for k, v in names.items()}, (3, 1, 0x409)
  385. )
  386. for k, v in names.items():
  387. log.debug(f"Adding name record {k}: {v}")
  388. name_table.setName(v, *k)
  389. # Replace occurences of old family name in untouched records
  390. skip_ids = [i.numerator for i in NameID]
  391. for r in ttFont["name"].names:
  392. if r.nameID in skip_ids:
  393. continue
  394. current = r.toUnicode()
  395. if existing_name not in current:
  396. continue
  397. if " " not in current:
  398. replacement = current.replace(existing_name, family_name).replace(" ", "")
  399. else:
  400. replacement = current.replace(existing_name, family_name)
  401. ttFont["name"].setName(
  402. replacement, r.nameID, r.platformID, r.platEncID, r.langID
  403. )
  404. def build_static_name_table_v1(ttFont, family_name, style_name):
  405. """Pre VF name tables, this version can only accept wght + ital"""
  406. non_weight_tokens = []
  407. v1_tokens = []
  408. tokens = style_name.split()
  409. for t in tokens:
  410. if t not in GF_STATIC_STYLES:
  411. non_weight_tokens.append(t)
  412. else:
  413. v1_tokens.append(t)
  414. family_tokens = family_name.split()
  415. new_family_name = []
  416. for t in family_tokens:
  417. if t in non_weight_tokens or t in new_family_name:
  418. continue
  419. new_family_name.append(t)
  420. for t in non_weight_tokens:
  421. new_family_name.append(t)
  422. family_name = " ".join(new_family_name)
  423. style_name = " ".join(v1_tokens).replace("Regular Italic", "Italic").strip()
  424. style_name = style_name or "Regular"
  425. log.debug(f"New family name: {family_name}")
  426. log.debug(f"New style name: {style_name}")
  427. build_static_name_table(ttFont, family_name, style_name)
  428. def build_filename(ttFont):
  429. name_table = ttFont["name"]
  430. family_name = name_table.getBestFamilyName()
  431. style_name = name_table.getBestSubFamilyName()
  432. _, ext = os.path.splitext(ttFont.reader.file.name)
  433. if is_variable(ttFont):
  434. is_italic = "Italic" in style_name
  435. axes = _fvar_dflts(ttFont).keys()
  436. axes = sorted([a for a in axes if a.isupper()]) + sorted(
  437. [a for a in axes if a.islower()]
  438. )
  439. if is_italic:
  440. return f"{family_name}-Italic[{','.join(axes)}]{ext}".replace(" ", "")
  441. return f"{family_name}[{','.join(axes)}]{ext}".replace(" ", "")
  442. return f"{family_name}-{style_name}{ext}".replace(" ", "")
  443. def dump(table, ttFont=None):
  444. return "\n".join(getXML(table.toXML, ttFont))