from typing import cast
from fontTools.ttLib.tables._f_v_a_r import Axis, NamedInstance
from fontTools.varLib.instancer import OverlapMode, instantiateVariableFont
from foundrytools import Font
from foundrytools.constants import T_GSUB, NameIds
[docs]
class Var2StaticError(Exception):
"""Raised when an error occurs during the conversion of a variable font to a static font."""
[docs]
class BadInstanceError(Exception):
"""Raised if the instance is invalid."""
[docs]
def check_update_name_table(var_font: Font) -> None:
"""
Check if the name table can be updated when creating a static instance.
This method should be called once by third-party applications before starting the conversion
process. Calling it at every iteration is not necessary and slows down the process.
:param var_font: The variable font to check and update.
:type var_font: Font
:raises UpdateNameTableError: If the 'STAT' table, Axis Values, or named instances are missing,
or if an error occurs during the creation of the static instance.
"""
try:
create_static_instance(var_font, var_font.t_fvar.table.instances[0], True)
except Exception as e:
raise UpdateNameTableError(str(e)) from e
[docs]
def check_instance(var_font: Font, instance: NamedInstance) -> None:
"""
Check if the instance is valid.
:param var_font: The variable font.
:type var_font: Font
:param instance: The named instance.
:type instance: NamedInstance
:raises BadInstanceError: If the instance is invalid.
"""
axes: list[Axis] = var_font.t_fvar.table.axes
for axis_tag, value in instance.coordinates.items():
axis_obj = next((a for a in axes if a.axisTag == axis_tag), None)
if axis_obj is None:
raise BadInstanceError(f"Cannot create static font: '{axis_tag}' not present in fvar")
if not axis_obj.minValue <= value <= axis_obj.maxValue:
raise BadInstanceError(
f"Cannot create static font: '{axis_tag}' out of bounds "
f"(value: {value} min: {axis_obj.minValue} max: {axis_obj.maxValue})"
)
[docs]
def get_existing_instance(var_font: Font, instance: NamedInstance) -> tuple[bool, NamedInstance]:
"""
Returns a named instance if the instance coordinates are the same, otherwise the custom
instance.
:param var_font: The variable font.
:type var_font: Font
:param instance: The named instance.
:type instance: NamedInstance
:return: A tuple with a boolean indicating if the instance is named and the instance object.
:rtype: tuple[bool, NamedInstance]
"""
for existing_instance in var_font.t_fvar.table.instances:
if existing_instance.coordinates == instance.coordinates:
return True, existing_instance
return False, instance
[docs]
def create_static_instance(
var_font: Font, instance: NamedInstance, update_font_names: bool, overlap: int = 1
) -> Font:
"""
Create a static instance from a variable font.
:param var_font: The variable font.
:type var_font: Font
:param instance: A named instance with axis values.
:type instance: NamedInstance
:param update_font_names: If ``True``, update the font names in the static instance.
:type update_font_names: bool
:param overlap: The overlap mode. Defaults to 1 (KEEP_AND_SET_FLAGS).
:type overlap: OverlapMode
:return: A static instance of the font.
:rtype: Font
"""
# We need to cast the overlap mode to the correct type.
overlap = cast(OverlapMode, overlap)
return Font(
instantiateVariableFont(
var_font.ttfont,
axisLimits=instance.coordinates,
inplace=False,
optimize=True,
overlap=overlap,
updateFontNames=update_font_names,
)
)
[docs]
def cleanup_static_font(static_font: Font) -> None:
"""
Clean up the static font by removing tables left by ``InstantiateVariableFont`` and remapping
the name IDs.
:param static_font: The static font to clean up.
:type static_font: Font
"""
tables_to_remove = ["cvar", "STAT"]
for table_tag in tables_to_remove:
if table_tag in static_font.ttfont:
del static_font.ttfont[table_tag]
static_font.t_name.build_unique_identifier()
# Remove unnecessary NameRecords and Macintosh-specific NameRecords, and remap the name IDs in
# the GSUB table.
static_font.t_name.remove_names(name_ids=[25])
static_font.t_name.remove_mac_names()
_remove_unused_names(static_font) # This is faster than removeUnusedNames.
name_ids_map = static_font.t_name.remap_name_ids()
if T_GSUB in static_font.ttfont:
static_font.t_gsub.remap_ui_name_ids(name_ids_map)
[docs]
def _remove_unused_names(static_font: Font) -> None:
"""
The method ``removeUnusedNames`` is very slow. This should be enough for most cases.
"""
if T_GSUB not in static_font.ttfont:
return
ui_name_ids = static_font.t_gsub.get_ui_name_ids()
name_ids_to_remove = [
name.nameID
for name in static_font.t_name.table.names
if name.nameID >= 256 and name.nameID not in ui_name_ids
]
static_font.t_name.remove_names(name_ids=name_ids_to_remove)
[docs]
def update_name_table(var_font: Font, static_font: Font, instance: NamedInstance) -> None:
"""
Update the name table of the static font in case ``InstantiateVariableFont`` could not update
it, or if the instance is non-existing.
:param var_font: The variable font.
:type var_font: Font
:param static_font: The static font.
:type static_font: Font
:param instance: The named instance.
:type instance: NamedInstance
"""
family_name = var_font.t_name.get_best_family_name()
subfamily_name = "_".join([f"{k}_{v}" for k, v in instance.coordinates.items()])
postscript_name = f"{family_name}-{subfamily_name}".replace(" ", "").replace(".", "_")
# Build the name table of the static font.
static_font.t_name.set_name(NameIds.FAMILY_NAME, f"{family_name} {subfamily_name}")
static_font.t_name.set_name(NameIds.POSTSCRIPT_NAME, postscript_name)
static_font.t_name.set_name(NameIds.TYPO_FAMILY_NAME, family_name)
static_font.t_name.set_name(NameIds.TYPO_SUBFAMILY_NAME, subfamily_name)
static_font.t_name.build_full_font_name()
[docs]
def run(
var_font: Font,
instance: NamedInstance,
update_font_names: bool = True,
overlap: int = 1,
) -> tuple[Font, str]:
"""
Convert a variable font to a static font.
:param var_font: The variable font to convert.
:type var_font: Font
:param instance: The named instance to use.
:type instance: NamedInstance
:param update_font_names: Whether to update the font names in the name table. Defaults to True.
:type update_font_names: bool
:param overlap: The overlap mode. Defaults to 1 (KEEP_AND_SET_FLAGS).
:type overlap: int
:return: The static font and the file stem.
:rtype: Optional[tuple[Font, str]]
"""
if not var_font.is_variable:
raise Var2StaticError("The font is not a variable font.")
try:
# Checks if the instance has valid axes and coordinates are within the axis limits. If not,
# raises a BadInstanceError.
check_instance(var_font, instance)
# If the instance coordinates are the same as an existing instance, we use the existing
# instance instead of the original one. This allows to access the instance postscriptNameID
# and subfamilyNameID and to update the name table.
is_existing_instance, instance = get_existing_instance(var_font, instance)
if is_existing_instance:
static_font = create_static_instance(var_font, instance, update_font_names, overlap)
else:
static_font = create_static_instance(var_font, instance, False, overlap)
# We update the name table with the instance coordinates if the instance is non-existing or
# if the name table cannot be updated.
if not is_existing_instance or not update_font_names:
update_name_table(var_font, static_font, instance)
cleanup_static_font(static_font)
file_name = static_font.t_name.get_debug_name(NameIds.POSTSCRIPT_NAME)
file_name += static_font.get_file_ext()
return static_font, file_name
except Exception as e:
raise Var2StaticError(str(e)) from e