Skip to content

ft2font: Add internal API for accessing font variations#31434

Open
QuLogic wants to merge 1 commit intomatplotlib:text-overhaulfrom
QuLogic:font-variations
Open

ft2font: Add internal API for accessing font variations#31434
QuLogic wants to merge 1 commit intomatplotlib:text-overhaulfrom
QuLogic:font-variations

Conversation

@QuLogic
Copy link
Copy Markdown
Member

@QuLogic QuLogic commented Apr 1, 2026

PR summary

This is internal API for querying font variations and setting them. FreeType sometimes calls them MM (multiple master) vars or variation axes, but I tried to stick to variations/variation axis. For the named styles, FreeType sometimes calls that a named style or a named instance, and I tried to use named style everywhere.

Font variations essentially allow fonts to have multiple styles in one by varying some parameter along an "Axis". For example, one font can contain all weights with a Weight axis ranging from Thin to Extra Bold, or an upright and italic font with a Slant axis for the angle. Some fonts may contain named styles which group some specific settings together under a a name (e.g., Bold may be Weight=700 or whatever the designer decides.)

Current internal API additions are:

  • FT2Font.get_variation_descriptor which returns a list of the variation axes and a list of the named styles.
  • FT2Font.get_default_variation_style which returns the index of the default name style (in the above list.)
  • FT2Font.get_variations which returns the float values for each axis (in the order of the above list.)
  • FT2Font.set_variations which allows setting the float values for each axis as a list, or selecting a named style by index.

This does not contain any public API; for that there are some questions that we might need to think about:

  • Should users select a font (by name or otherwise) and then apply variations with something like Text.set_fontvariations and/or FontProperties.set_variations?
  • Or, should variable fonts match anything within their range?
    • That is, if a variable font has a Weight axis between 100 and 1000, should that font automatically match ("FontName", weight="bold") / ("FontName", weight="regular"), etc.
    • If a variable font has a named style for, e.g., Bold, should that automatically add a name of "FontName Bold" as well (as in ENH: Allow fonts to be addressed by any of their SFNT family names #31183)?

I do not expect this to be ready for 3.11 until we discuss some of these options.

AI Disclosure

None

PR checklist

@github-project-automation github-project-automation bot moved this to Waiting for other PR in Font and text overhaul Apr 1, 2026
@tacaswell tacaswell added this to the v3.12.0 milestone Apr 2, 2026
@QuLogic
Copy link
Copy Markdown
Member Author

QuLogic commented Apr 3, 2026

As an example, one can look at Roboto Flex, which has 13 axes (though more than half are hidden):

>>> from matplotlib.ft2font import FT2Font
>>> font = FT2Font('RobotoFlex-VariableFont.ttf')
>>> axes, styles = font.get_variation_descriptor()
>>> for axis in axes:
...     print(axis)
VariationAxis(name='OpticalSize', minimum=8, default=14, maximum=144, tag=1869640570, names={(3, 1, 1033): 'Optical Size'}, flags=VarAxisFlags.DEFAULT)
VariationAxis(name='Weight', minimum=100, default=400, maximum=1000, tag=2003265652, names={(3, 1, 1033): 'Weight'}, flags=VarAxisFlags.DEFAULT)
VariationAxis(name='GRAD', minimum=-200, default=0, maximum=150, tag=1196572996, names={(3, 1, 1033): 'Grade'}, flags=VarAxisFlags.DEFAULT)
VariationAxis(name='Width', minimum=25, default=100, maximum=151, tag=2003072104, names={(3, 1, 1033): 'Width'}, flags=VarAxisFlags.DEFAULT)
VariationAxis(name='Slant', minimum=-10, default=0, maximum=0, tag=1936486004, names={(3, 1, 1033): 'Slant'}, flags=VarAxisFlags.DEFAULT)
VariationAxis(name='XOPQ', minimum=27, default=96, maximum=175, tag=1481592913, names={(3, 1, 1033): 'Parametric Thick Stroke'}, flags=VarAxisFlags.HIDDEN)
VariationAxis(name='YOPQ', minimum=25, default=79, maximum=135, tag=1498370129, names={(3, 1, 1033): 'Parametric Thin Stroke'}, flags=VarAxisFlags.HIDDEN)
VariationAxis(name='XTRA', minimum=323, default=468, maximum=603, tag=1481921089, names={(3, 1, 1033): 'Parametric Counter Width'}, flags=VarAxisFlags.HIDDEN)
VariationAxis(name='YTUC', minimum=528, default=712, maximum=760, tag=1498699075, names={(3, 1, 1033): 'Parametric Uppercase Height'}, flags=VarAxisFlags.HIDDEN)
VariationAxis(name='YTLC', minimum=416, default=514, maximum=570, tag=1498696771, names={(3, 1, 1033): 'Parametric Lowercase Height'}, flags=VarAxisFlags.HIDDEN)
VariationAxis(name='YTAS', minimum=649, default=750, maximum=854, tag=1498693971, names={(3, 1, 1033): 'Parametric Ascender Height'}, flags=VarAxisFlags.HIDDEN)
VariationAxis(name='YTDE', minimum=-305, default=-203, maximum=-98, tag=1498694725, names={(3, 1, 1033): 'Parametric Descender Depth'}, flags=VarAxisFlags.HIDDEN)
VariationAxis(name='YTFI', minimum=560, default=738, maximum=788, tag=1498695241, names={(3, 1, 1033): 'Parametric Figure Height'}, flags=VarAxisFlags.HIDDEN)
>>> for style in styles:
...     print(style)
VariationNamedStyle(names={(3, 1, 1033): 'Thin'}, psnames=None)
VariationNamedStyle(names={(3, 1, 1033): 'ExtraLight'}, psnames=None)
VariationNamedStyle(names={(3, 1, 1033): 'Light'}, psnames=None)
VariationNamedStyle(names={(3, 1, 1033): 'Regular'}, psnames=None)
VariationNamedStyle(names={(3, 1, 1033): 'Medium'}, psnames=None)
VariationNamedStyle(names={(3, 1, 1033): 'SemiBold'}, psnames=None)
VariationNamedStyle(names={(3, 1, 1033): 'Bold'}, psnames=None)
VariationNamedStyle(names={(3, 1, 1033): 'ExtraBold'}, psnames=None)
VariationNamedStyle(names={(3, 1, 1033): 'Black'}, psnames=None)
VariationNamedStyle(names={(3, 1, 1033): 'ExtraBlack'}, psnames=None)
VariationNamedStyle(names={(3, 1, 1033): 'Thin Italic'}, psnames=None)
VariationNamedStyle(names={(3, 1, 1033): 'ExtraLight Italic'}, psnames=None)
VariationNamedStyle(names={(3, 1, 1033): 'Light Italic'}, psnames=None)
VariationNamedStyle(names={(3, 1, 1033): 'Italic'}, psnames=None)
VariationNamedStyle(names={(3, 1, 1033): 'Medium Italic'}, psnames=None)
VariationNamedStyle(names={(3, 1, 1033): 'SemiBold Italic'}, psnames=None)
VariationNamedStyle(names={(3, 1, 1033): 'Bold Italic'}, psnames=None)
VariationNamedStyle(names={(3, 1, 1033): 'ExtraBold Italic'}, psnames=None)
VariationNamedStyle(names={(3, 1, 1033): 'Black Italic'}, psnames=None)
VariationNamedStyle(names={(3, 1, 1033): 'ExtraBlack Italic'}, psnames=None)
>>> font.get_default_variation_style()
4
>>> font.get_variations()
[14.0, 400.0, 0.0, 100.0, 0.0, 96.0, 79.0, 468.0, 712.0, 514.0, 750.0, -203.0, 738.0]
>>> font.set_variations([i + 20 for i in font.get_variations()])  # Change all variation axes.
>>> font.get_variations()
[34.0, 420.0, 20.0, 120.0, 20.0, 116.0, 99.0, 488.0, 732.0, 534.0, 770.0, -183.0, 758.0]
>>> font.set_variations(11)  # This is Thin Italic (it appears to be 1-based index; we may want to change that)
>>> font.get_variations()
[14.0, 100.0, 0.0, 100.0, -10.0, 96.0, 79.0, 468.0, 712.0, 514.0, 750.0, -203.0, 738.0]  # Now [1] (weight) is 100 and [4] (slant) is -10

You can see an example of using these font variations in mplcairo. Note that mplcairo uses the suffix-the-filename approach that was also used for font features. We likely won't do that here since we didn't do that for font features either.

mplcairo calls Cairo's cairo_font_options_set_variations which takes a string of comma-separated tag=value settings. The tags in the above output are currently 32-bit integers, but if we treat them as 4 bytes in ASCII, they should correspond with what Cairo uses.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: Waiting for other PR

Development

Successfully merging this pull request may close these issues.

2 participants