529 lines
19 KiB
Python
529 lines
19 KiB
Python
from copy import deepcopy
|
|
from fontTools.ttLib import TTFont
|
|
from fontTools.misc.testTools import getXML
|
|
from fontTools.otlLib.builder import buildStatTable
|
|
from fontTools.varLib.instancer.names import _updateUniqueIdNameRecord, NameID
|
|
from fontTools.ttLib.tables._f_v_a_r import NamedInstance
|
|
from pkg_resources import resource_filename
|
|
from google.protobuf import text_format
|
|
from collections import OrderedDict
|
|
from axisregistry.axes_pb2 import AxisProto
|
|
from collections import defaultdict
|
|
from itertools import chain
|
|
import logging
|
|
from glob import glob
|
|
import os
|
|
|
|
try:
|
|
from ._version import version as __version__ # type: ignore
|
|
except ImportError:
|
|
__version__ = "0.0.0+unknown"
|
|
|
|
log = logging.getLogger(__file__)
|
|
|
|
# TODO we may have more of these. Please note that some applications may not
|
|
# implement variable font style linking.
|
|
LINKED_VALUES = {
|
|
"wght": {400.0: 700.0},
|
|
"ital": {0.0: 1.0},
|
|
}
|
|
|
|
# Static font styles. The GF api only support the following static font styles
|
|
GF_STATIC_STYLES = OrderedDict(
|
|
[
|
|
("Thin", 100),
|
|
("ExtraLight", 200),
|
|
("Light", 300),
|
|
("Regular", 400),
|
|
("Medium", 500),
|
|
("SemiBold", 600),
|
|
("Bold", 700),
|
|
("ExtraBold", 800),
|
|
("Black", 900),
|
|
("Thin Italic", 100),
|
|
("ExtraLight Italic", 200),
|
|
("Light Italic", 300),
|
|
("Italic", 400),
|
|
("Medium Italic", 500),
|
|
("SemiBold Italic", 600),
|
|
("Bold Italic", 700),
|
|
("ExtraBold Italic", 800),
|
|
("Black Italic", 900),
|
|
]
|
|
)
|
|
|
|
|
|
def load_protobuf(klass, path):
|
|
message = klass()
|
|
with open(path, "rb") as text_data:
|
|
text_format.Merge(text_data.read(), message)
|
|
return message
|
|
|
|
|
|
class AxisRegistry:
|
|
def __init__(self, fp=resource_filename("axisregistry", "data")):
|
|
axis_fps = [fp for fp in glob(os.path.join(fp, "*.textproto"))]
|
|
self._data = {}
|
|
for fp in axis_fps:
|
|
axis = load_protobuf(AxisProto, fp)
|
|
self._data[axis.tag] = axis
|
|
|
|
def __getitem__(self, k):
|
|
return self._data[k]
|
|
|
|
def __iter__(self):
|
|
for i in self._data.keys():
|
|
yield i
|
|
|
|
def keys(self):
|
|
return self._data.keys()
|
|
|
|
def items(self):
|
|
return self._data.items()
|
|
|
|
def get_fallback(self, name):
|
|
for a in self:
|
|
for fallback in self[a].fallback:
|
|
if name == fallback.name:
|
|
return a, fallback
|
|
return None, None
|
|
|
|
def fallbacks_in_fvar(self, ttFont):
|
|
res = defaultdict(list)
|
|
axes_in_font = {
|
|
a.axisTag: {"min": a.minValue, "max": a.maxValue}
|
|
for a in ttFont["fvar"].axes
|
|
}
|
|
for axis in axes_in_font:
|
|
if axis not in self.keys():
|
|
log.warn(f"Axis {axis} not found in GF Axis Registry!")
|
|
continue
|
|
for fallback in self[axis].fallback:
|
|
if (
|
|
fallback.value < axes_in_font[axis]["min"]
|
|
or fallback.value > axes_in_font[axis]["max"]
|
|
):
|
|
continue
|
|
res[axis].append(fallback)
|
|
return res
|
|
|
|
def fallbacks_in_name_table(self, ttFont):
|
|
res = []
|
|
name_table = ttFont["name"]
|
|
tokens = (
|
|
name_table.getBestFamilyName().split()[1:]
|
|
+ name_table.getBestSubFamilyName().split()
|
|
)
|
|
fvar_axes_in_font = [a.axisTag for a in ttFont["fvar"].axes]
|
|
for token in tokens:
|
|
axis, fallback = axis_registry.get_fallback(token)
|
|
if any([not axis, axis in fvar_axes_in_font, fallback in res]):
|
|
continue
|
|
res.append((axis, fallback))
|
|
return res
|
|
|
|
def fallback_for_value(self, axis_tag, value):
|
|
if axis_tag in axis_registry:
|
|
return next(
|
|
(f for f in axis_registry[axis_tag].fallback if f.value == value),
|
|
None,
|
|
)
|
|
return None
|
|
|
|
|
|
axis_registry = AxisRegistry()
|
|
# sort user axes by alphabetical order and append presorted registered axes
|
|
AXIS_ORDER = sorted([i for i in axis_registry if i.isupper()]) + [
|
|
"opsz",
|
|
"wdth",
|
|
"wght",
|
|
"ital",
|
|
"slnt",
|
|
]
|
|
|
|
|
|
def is_variable(ttFont):
|
|
return "fvar" in ttFont
|
|
|
|
|
|
def _fvar_dflts(ttFont):
|
|
res = OrderedDict()
|
|
for a in ttFont["fvar"].axes:
|
|
fallback = axis_registry.fallback_for_value(a.axisTag, a.defaultValue)
|
|
if fallback:
|
|
name = fallback.name
|
|
elided = fallback.value == axis_registry[
|
|
a.axisTag
|
|
].default_value and name not in ["Regular", "Italic"]
|
|
else:
|
|
name = None
|
|
elided = True # since we can't find a name for it, keep it elided
|
|
res[a.axisTag] = {"value": a.defaultValue, "name": name, "elided": elided}
|
|
return res
|
|
|
|
|
|
def build_stat(ttFont, sibling_ttFonts=[]):
|
|
log.info("Building STAT table")
|
|
assert is_variable(ttFont), "not a VF!"
|
|
fallbacks_in_fvar = axis_registry.fallbacks_in_fvar(ttFont)
|
|
fallbacks_in_siblings = list(
|
|
chain.from_iterable(
|
|
axis_registry.fallbacks_in_name_table(f) for f in sibling_ttFonts
|
|
)
|
|
)
|
|
fallbacks_in_names = axis_registry.fallbacks_in_name_table(ttFont)
|
|
nametable = ttFont["name"]
|
|
fvar = ttFont["fvar"]
|
|
|
|
# rm old STAT table and associated name table records
|
|
fvar_instance_nameids = set(i.subfamilyNameID for i in fvar.instances)
|
|
fvar_axis_nameids = set(a.axisNameID for a in fvar.axes)
|
|
fvar_nameids = fvar_axis_nameids | fvar_instance_nameids
|
|
# These NameIDs are required for applications to work correctly so
|
|
# they cannot be deleted.
|
|
# https://learn.microsoft.com/en-us/typography/opentype/spec/name
|
|
keep_nameids = set(range(26)) | fvar_nameids
|
|
if "STAT" in ttFont:
|
|
stat = ttFont["STAT"]
|
|
if stat.table.AxisValueCount > 0:
|
|
axis_values = stat.table.AxisValueArray.AxisValue
|
|
for ax in axis_values:
|
|
if ax.ValueNameID not in keep_nameids:
|
|
nametable.removeNames(nameID=ax.ValueNameID)
|
|
if stat.table.DesignAxisCount > 0:
|
|
axes = stat.table.DesignAxisRecord.Axis
|
|
for ax in axes:
|
|
if ax.AxisNameID not in keep_nameids:
|
|
nametable.removeNames(nameID=ax.AxisNameID)
|
|
del ttFont["STAT"]
|
|
|
|
res = []
|
|
# use fontTools build_stat. Link contains function params and usage example
|
|
# https://github.com/fonttools/fonttools/blob/a293606fc8c88af8510d0688a6a36271ff4ff350/Lib/fontTools/otlLib/builder.py#L2683
|
|
seen_axes = set()
|
|
for axis, fallbacks in fallbacks_in_fvar.items():
|
|
seen_axes.add(axis)
|
|
a = {"tag": axis, "name": axis_registry[axis].display_name, "values": []}
|
|
for fallback in fallbacks:
|
|
a["values"].append(
|
|
{
|
|
"name": fallback.name,
|
|
"value": fallback.value,
|
|
# include flags and linked values
|
|
"flags": 0x2
|
|
if fallback.value == axis_registry[axis].default_value
|
|
else 0x0,
|
|
}
|
|
)
|
|
if axis in LINKED_VALUES and fallback.value in LINKED_VALUES[axis]:
|
|
a["values"][-1]["linkedValue"] = LINKED_VALUES[axis][fallback.value]
|
|
res.append(a)
|
|
|
|
for axis, fallback in fallbacks_in_names:
|
|
if axis in seen_axes:
|
|
continue
|
|
a = {
|
|
"tag": axis,
|
|
"name": axis_registry[axis].display_name,
|
|
"values": [{"name": fallback.name, "value": fallback.value, "flags": 0x0}],
|
|
}
|
|
if axis in LINKED_VALUES and fallback.value in LINKED_VALUES[axis]:
|
|
a["values"][0]["linkedValue"] = LINKED_VALUES[axis][fallback.value]
|
|
res.append(a)
|
|
|
|
for axis, fallback in fallbacks_in_siblings:
|
|
if axis in seen_axes:
|
|
continue
|
|
elided_value = axis_registry[axis].default_value
|
|
elided_fallback = axis_registry.fallback_for_value(axis, elided_value)
|
|
a = {
|
|
"tag": axis,
|
|
"name": axis_registry[axis].display_name,
|
|
"values": [
|
|
{"name": elided_fallback.name, "value": elided_value, "flags": 0x2}
|
|
],
|
|
}
|
|
if axis in LINKED_VALUES and elided_value in LINKED_VALUES[axis]:
|
|
a["values"][0]["linkedValue"] = LINKED_VALUES[axis][elided_value]
|
|
res.append(a)
|
|
buildStatTable(ttFont, res, macNames=False)
|
|
|
|
|
|
def build_name_table(ttFont, family_name=None, style_name=None, siblings=[]):
|
|
log.info("Building name table")
|
|
name_table = ttFont["name"]
|
|
family_name = family_name if family_name else name_table.getBestFamilyName()
|
|
style_name = style_name if style_name else name_table.getBestSubFamilyName()
|
|
if is_variable(ttFont):
|
|
return build_vf_name_table(ttFont, family_name, siblings=siblings)
|
|
return build_static_name_table_v1(ttFont, family_name, style_name)
|
|
|
|
|
|
def _fvar_instance_collisions(ttFont, siblings=[]):
|
|
"""Check if a font family is going to have colliding fvar instances.
|
|
|
|
Collision occur when a family has has 2+ roman styles or 2+ italic
|
|
styles."""
|
|
|
|
def is_italic(font):
|
|
return font["post"].italicAngle != 0.0
|
|
|
|
family_styles = [is_italic(f) for f in siblings + [ttFont]]
|
|
|
|
return len(family_styles) != len(set(family_styles))
|
|
|
|
|
|
def build_vf_name_table(ttFont, family_name, siblings=[]):
|
|
# VF name table should reflect the 0 origin of the font!
|
|
assert is_variable(ttFont), "Not a VF!"
|
|
style_name = _vf_style_name(ttFont, family_name)
|
|
if _fvar_instance_collisions(ttFont, siblings):
|
|
build_static_name_table_v1(ttFont, family_name, style_name)
|
|
else:
|
|
build_static_name_table(ttFont, family_name, style_name)
|
|
build_variations_ps_name(ttFont, family_name)
|
|
|
|
|
|
def build_variations_ps_name(ttFont, family_name=None):
|
|
assert is_variable(ttFont), "Not a VF!"
|
|
if not family_name:
|
|
family_name = ttFont["name"].getBestFamilyName()
|
|
font_styles = axis_registry.fallbacks_in_name_table(ttFont)
|
|
if font_styles:
|
|
vf_ps = family_name.replace(" ", "") + "".join(
|
|
[
|
|
fallback.name
|
|
for _, fallback in font_styles
|
|
if fallback.name not in family_name
|
|
]
|
|
)
|
|
else:
|
|
vf_ps = family_name.replace(" ", "")
|
|
ttFont["name"].setName(vf_ps, NameID.VARIATIONS_POSTSCRIPT_NAME_PREFIX, 3, 1, 0x409)
|
|
|
|
|
|
def _vf_style_name(ttFont, family_name):
|
|
fvar_dflts = _fvar_dflts(ttFont)
|
|
res = []
|
|
for axis_name in AXIS_ORDER:
|
|
if axis_name not in fvar_dflts:
|
|
continue
|
|
value = fvar_dflts[axis_name]
|
|
if not value["elided"]:
|
|
res.append(value["name"])
|
|
|
|
family_name_tokens = family_name.split()
|
|
font_styles = axis_registry.fallbacks_in_name_table(ttFont)
|
|
for _, fallback in font_styles:
|
|
if fallback.name not in res and fallback.name not in family_name_tokens:
|
|
res.append(fallback.name)
|
|
|
|
name = " ".join(res).replace("Regular Italic", "Italic")
|
|
log.debug(f"Built VF style name: '{name}'")
|
|
return name
|
|
|
|
|
|
def build_fvar_instances(ttFont, axis_dflts={}):
|
|
"""Replace a variable font's fvar instances with a set of new instances
|
|
which conform to the Google Fonts instance spec:
|
|
https://github.com/googlefonts/gf-docs/tree/main/Spec#fvar-instances
|
|
"""
|
|
assert is_variable(ttFont), "Not a VF!"
|
|
log.info("Building fvar instances")
|
|
fvar = ttFont["fvar"]
|
|
name_table = ttFont["name"]
|
|
style_name = name_table.getBestSubFamilyName()
|
|
|
|
# Protect name IDs which are shared with the STAT table
|
|
stat_nameids = []
|
|
if "STAT" in ttFont:
|
|
if ttFont["STAT"].table.AxisValueCount > 0:
|
|
stat_nameids.extend(
|
|
av.ValueNameID for av in ttFont["STAT"].table.AxisValueArray.AxisValue
|
|
)
|
|
if ttFont["STAT"].table.DesignAxisCount > 0:
|
|
stat_nameids.extend(
|
|
av.AxisNameID for av in ttFont["STAT"].table.DesignAxisRecord.Axis
|
|
)
|
|
|
|
# rm old fvar subfamily and ps name records
|
|
for inst in fvar.instances:
|
|
if inst.subfamilyNameID not in [2, 17] + stat_nameids:
|
|
name_table.removeNames(nameID=inst.subfamilyNameID)
|
|
if inst.postscriptNameID not in [65535, 6]:
|
|
name_table.removeNames(nameID=inst.postscriptNameID)
|
|
|
|
fvar_dflts = _fvar_dflts(ttFont)
|
|
if not axis_dflts:
|
|
axis_dflts = {k: v["value"] for k, v in fvar_dflts.items()}
|
|
|
|
is_italic = "Italic" in style_name
|
|
is_roman_and_italic = any(a for a in ("slnt", "ital") if a in fvar_dflts)
|
|
|
|
fallbacks = axis_registry.fallbacks_in_fvar(ttFont)
|
|
# some families may not have a wght axis e.g
|
|
# https://fonts.google.com/specimen/League+Gothic
|
|
# these families just have a single weight which is Regular
|
|
if "wght" not in fvar_dflts:
|
|
fallback = next(
|
|
(f for f in axis_registry["wght"].fallback if f.value == 400.0), None
|
|
)
|
|
fallbacks["wght"] = [fallback]
|
|
|
|
wght_fallbacks = fallbacks["wght"]
|
|
|
|
ital_axis = next((a for a in fvar.axes if a.axisTag == "ital"), None)
|
|
slnt_axis = next((a for a in fvar.axes if a.axisTag == "slnt"), None)
|
|
|
|
def gen_instances(is_italic):
|
|
results = []
|
|
for fallback in wght_fallbacks:
|
|
name = fallback.name if not is_italic else f"{fallback.name} Italic".strip()
|
|
name = name.replace("Regular Italic", "Italic")
|
|
|
|
coordinates = {k: v for k, v in axis_dflts.items()}
|
|
if "wght" in fvar_dflts:
|
|
coordinates["wght"] = fallback.value
|
|
if is_italic:
|
|
if ital_axis:
|
|
coordinates["ital"] = ital_axis.minValue
|
|
elif slnt_axis:
|
|
coordinates["slnt"] = slnt_axis.minValue
|
|
|
|
inst = NamedInstance()
|
|
inst.subfamilyNameID = name_table.addName(name)
|
|
inst.coordinates = coordinates
|
|
log.debug(f"Adding fvar instance: {name}: {coordinates}")
|
|
results.append(inst)
|
|
return results
|
|
|
|
instances = []
|
|
if is_roman_and_italic:
|
|
for bool_ in (False, True):
|
|
instances += gen_instances(is_italic=bool_)
|
|
elif is_italic:
|
|
instances += gen_instances(is_italic=True)
|
|
else:
|
|
instances += gen_instances(is_italic=False)
|
|
fvar.instances = instances
|
|
|
|
|
|
def build_static_name_table(ttFont, family_name, style_name):
|
|
# stip mac names
|
|
name_table = ttFont["name"]
|
|
name_table.removeNames(platformID=1)
|
|
existing_name = ttFont["name"].getBestFamilyName()
|
|
|
|
names = {}
|
|
is_ribbi = (
|
|
True if style_name in ("Regular", "Italic", "Bold", "Bold Italic") else False
|
|
)
|
|
if is_ribbi:
|
|
full_name = f"{family_name} {style_name}"
|
|
ps_name = f"{family_name}-{style_name}".replace(" ", "")
|
|
names[(NameID.FAMILY_NAME, 3, 1, 0x409)] = family_name
|
|
names[(NameID.SUBFAMILY_NAME, 3, 1, 0x409)] = style_name
|
|
names[(NameID.FULL_FONT_NAME, 3, 1, 0x409)] = full_name
|
|
names[(NameID.POSTSCRIPT_NAME, 3, 1, 0x409)] = ps_name
|
|
for name_id in (
|
|
NameID.TYPOGRAPHIC_FAMILY_NAME,
|
|
NameID.TYPOGRAPHIC_SUBFAMILY_NAME,
|
|
21,
|
|
22,
|
|
):
|
|
name_table.removeNames(nameID=name_id)
|
|
else:
|
|
style_tokens = style_name.split()
|
|
new_family_name = family_name.split()
|
|
is_italic = "Italic" in style_tokens
|
|
for t in style_tokens:
|
|
if t in ["Regular", "Italic"] or t in new_family_name:
|
|
continue
|
|
new_family_name.append(t)
|
|
new_family_name = " ".join(new_family_name)
|
|
new_style_name = "Italic" if is_italic else "Regular"
|
|
full_name = f"{family_name} {style_name}"
|
|
ps_name = f"{family_name}-{style_name}".replace(" ", "")
|
|
|
|
names[(NameID.FAMILY_NAME, 3, 1, 0x409)] = new_family_name
|
|
names[(NameID.SUBFAMILY_NAME, 3, 1, 0x409)] = new_style_name
|
|
names[(NameID.FULL_FONT_NAME, 3, 1, 0x409)] = full_name
|
|
names[(NameID.POSTSCRIPT_NAME, 3, 1, 0x409)] = ps_name
|
|
names[(NameID.TYPOGRAPHIC_FAMILY_NAME, 3, 1, 0x409)] = family_name
|
|
names[(NameID.TYPOGRAPHIC_SUBFAMILY_NAME, 3, 1, 0x409)] = style_name
|
|
# we do not use WWS names since we use the RIBBI naming schema
|
|
for name_id in (21, 22):
|
|
name_table.removeNames(nameID=name_id)
|
|
|
|
names[(NameID.UNIQUE_FONT_IDENTIFIER, 3, 1, 0x409)] = _updateUniqueIdNameRecord(
|
|
ttFont, {k[0]: v for k, v in names.items()}, (3, 1, 0x409)
|
|
)
|
|
for k, v in names.items():
|
|
log.debug(f"Adding name record {k}: {v}")
|
|
name_table.setName(v, *k)
|
|
|
|
# Replace occurences of old family name in untouched records
|
|
skip_ids = [i.numerator for i in NameID]
|
|
for r in ttFont["name"].names:
|
|
if r.nameID in skip_ids:
|
|
continue
|
|
current = r.toUnicode()
|
|
if existing_name not in current:
|
|
continue
|
|
if " " not in current:
|
|
replacement = current.replace(existing_name, family_name).replace(" ", "")
|
|
else:
|
|
replacement = current.replace(existing_name, family_name)
|
|
ttFont["name"].setName(
|
|
replacement, r.nameID, r.platformID, r.platEncID, r.langID
|
|
)
|
|
|
|
|
|
def build_static_name_table_v1(ttFont, family_name, style_name):
|
|
"""Pre VF name tables, this version can only accept wght + ital"""
|
|
non_weight_tokens = []
|
|
v1_tokens = []
|
|
tokens = style_name.split()
|
|
for t in tokens:
|
|
if t not in GF_STATIC_STYLES:
|
|
non_weight_tokens.append(t)
|
|
else:
|
|
v1_tokens.append(t)
|
|
|
|
family_tokens = family_name.split()
|
|
new_family_name = []
|
|
for t in family_tokens:
|
|
if t in non_weight_tokens or t in new_family_name:
|
|
continue
|
|
new_family_name.append(t)
|
|
for t in non_weight_tokens:
|
|
new_family_name.append(t)
|
|
|
|
family_name = " ".join(new_family_name)
|
|
style_name = " ".join(v1_tokens).replace("Regular Italic", "Italic").strip()
|
|
style_name = style_name or "Regular"
|
|
log.debug(f"New family name: {family_name}")
|
|
log.debug(f"New style name: {style_name}")
|
|
build_static_name_table(ttFont, family_name, style_name)
|
|
|
|
|
|
def build_filename(ttFont):
|
|
name_table = ttFont["name"]
|
|
family_name = name_table.getBestFamilyName()
|
|
style_name = name_table.getBestSubFamilyName()
|
|
_, ext = os.path.splitext(ttFont.reader.file.name)
|
|
if is_variable(ttFont):
|
|
is_italic = "Italic" in style_name
|
|
axes = _fvar_dflts(ttFont).keys()
|
|
axes = sorted([a for a in axes if a.isupper()]) + sorted(
|
|
[a for a in axes if a.islower()]
|
|
)
|
|
if is_italic:
|
|
return f"{family_name}-Italic[{','.join(axes)}]{ext}".replace(" ", "")
|
|
return f"{family_name}[{','.join(axes)}]{ext}".replace(" ", "")
|
|
return f"{family_name}-{style_name}{ext}".replace(" ", "")
|
|
|
|
|
|
def dump(table, ttFont=None):
|
|
return "\n".join(getXML(table.toXML, ttFont))
|