6 Font Variations
dxtr edited this page 2026-10-18 16:23:02 +02:00
This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

Font variations

cl-ft2 supports OpenType variable fonts (fvar/gvar, CFF2), and Adobe Multiple Masters fonts, which FreeType presents through the same interface. A variable font has one or more axes, such as weight (wght) or width (wdth), and usually a set of named instances, such as "Thin", "Regular" and "Black", at fixed points on those axes.

The examples use tests/fonts/cl-ft2-test-variable.ttf from the repository. It has wght 100–900 (default 400), wdth 75–125 (default 100), and the instances Thin, Regular and Black.

Inspecting

face-variable-p face → boolean

True if the face has variation axes (:multiple-masters in face-flags).

face-variation-axes face → list of var-axis

The axes in axis order. The list is empty for a face without variations.

(ft2:face-variation-axes face)
;; => (#S(VAR-AXIS :TAG "wght" :NAME "Weight" :MINIMUM 100 :DEFAULT 400 :MAXIMUM 900
;;                 :HIDDEN-P NIL :INDEX 0)
;;     #S(VAR-AXIS :TAG "wdth" :NAME "Width" :MINIMUM 75 :DEFAULT 100 :MAXIMUM 125
;;                 :HIDDEN-P NIL :INDEX 1))
Accessor Meaning
var-axis-tag four-character OpenType tag: "wght", "wdth", "slnt", "ital", "opsz", or a custom uppercase tag
var-axis-name display name, or nil
var-axis-minimum, var-axis-default, var-axis-maximum range, in design units (rationals)
var-axis-hidden-p true if the font asks not to show the axis in user interfaces
var-axis-index position in coordinate lists

face-named-instances face → list of named-instance

(ft2:face-named-instances face)
;; => (#S(NAMED-INSTANCE :NAME "Thin"    :POSTSCRIPT-NAME NIL :COORDINATES (100 100) :INDEX 0)
;;     #S(NAMED-INSTANCE :NAME "Regular" :POSTSCRIPT-NAME NIL :COORDINATES (400 100) :INDEX 1)
;;     #S(NAMED-INSTANCE :NAME "Black"   :POSTSCRIPT-NAME NIL :COORDINATES (900 100) :INDEX 2))
Accessor Meaning
named-instance-name the instance's subfamily name, from the name table, preferring US English Windows names
named-instance-postscript-name its PostScript name, or nil if the font gives none
named-instance-coordinates its design coordinates, in axis order
named-instance-index from 0, the value for face-named-instance and for open-face's :instance

Named instances are numbered from 0, with nil for none. FreeType numbers them from 1, with 0 for none; cl-ft2 converts. cl-harfbuzz uses the same convention as cl-ft2.

face-default-named-instance face → index or nil

The instance whose coordinates are the font's defaults (here 1, "Regular"), or nil if there is none.

Coordinates

Design coordinates are in each axis's own units: wght 100–900, wdth as a percentage, and so on. Normalized coordinates run from −1 (minimum) through 0 (default) to 1 (maximum).

Each place below is read with its function and set with setf. As usual for setf, setting returns the value you gave, not the clamped or padded result.

face-design-coordinates face → list · setf

The design coordinates in effect, one per axis in axis order.

Setting it takes a list of reals in axis order:

  • Axes past the end of the list take the font's default, not their current value.
  • Values outside an axis's range are clamped to it.
  • More values than axes signals :invalid-argument.
  • nil discards any coordinates set and returns the face to its named instance, or to the font's defaults if none is selected.
(ft2:face-design-coordinates face)                    ; => (400 100)   the defaults
(setf (ft2:face-design-coordinates face) '(900))      ; wght 900, wdth 100
(setf (ft2:face-design-coordinates face) '(2000 50))  ; clamped to (900 75)
(setf (ft2:face-design-coordinates face) '())         ; reset

Setting coordinates adds :variation to face-flags, unless the result is the font's default position.

face-axis-coordinate face tag → real · setf

The design coordinate of one axis, named by its tag. Setting it changes that axis, clamped to its range, and leaves the others as they are:

(setf (ft2:face-axis-coordinate face "wght") 650)
(ft2:face-axis-coordinate face "wght")       ; => 650
(ft2:face-design-coordinates face)           ; => (650 100)
(ft2:face-normalized-coordinates face)       ; => (1/2 0)
(member :variation (ft2:face-flags face))    ; => (:VARIATION)

A tag the face does not have signals :invalid-argument and lists the tags it does have. A tag is four characters: a string of another length is a type-error, which SBCL reports at compile time for a constant.

face-normalized-coordinates face → list · setf

The normalized coordinates in effect. Setting it works like face-design-coordinates: missing axes take 0, values are clamped to −1..1, and nil resets.

face-named-instance face → index or nil · setf

The instance last selected, by setting this place or by open-face :instance, or nil. Setting coordinates later moves the face away from that instance without deselecting it. :variation in face-flags tells you whether they have.

Setting it to an index (from 0) selects that instance. Setting it to nil selects none, which puts every axis at its default. Either way, any coordinates set since are discarded and :variation is cleared. An index that does not exist signals :invalid-argument.

(setf (ft2:face-named-instance face) 2)    ; Black
(ft2:face-design-coordinates face)         ; => (900 100)
(setf (ft2:face-named-instance face) nil)  ; defaults

You can also choose an instance when opening the face, which also sets the face's style name:

(ft2:open-face lib "Inter.ttf" :instance 2)

face-index reports only the face's index in its file. FreeType packs the instance into the same field, and face-named-instance is how you read it.

Coordinates in effect

The readers always report the coordinates actually in effect. FreeType's own getters can keep answering with the old values after a reset to defaults. cl-ft2 detects that state (no :variation flag and no named instance) and answers with the defaults itself. This FreeType quirk, and FreeType's two setters, which disagree on short and out-of-range lists, are handled in src/variation.lisp, where a comment explains them.

A FreeType bug: an instance selected again after a reset

cl-ft2 does not work around this one. Once a face has gone from a named instance whose coordinates were changed back to the font's defaults, selecting that named instance again, or setting its design coordinates, does nothing: glyphs go on loading at the defaults, and the readers report the defaults.

;; With tests/fonts/cl-ft2-test-variable.ttf
(setf (ft2:face-named-instance face) 2)            ; Black, wght 900
(setf (ft2:face-axis-coordinate face "wdth") 125)  ; changed
(setf (ft2:face-named-instance face) nil)          ; back to the defaults
(setf (ft2:face-named-instance face) 2)            ; Black again: no effect
(ft2:face-design-coordinates face)                 ; => (400 100), not (900 100)

Setting (setf (ft2:face-design-coordinates face) '(900 100)) in place of the last selection has no effect either. Selecting another named instance, or setting other coordinates, before going back to the instance avoids the bug, as does opening the face again.

It was seen with FreeType 2.14.3, and the code responsible is the same in 2.13.1, the oldest FreeType cl-ft2 takes, and in FreeType's development sources of October 2026. Asked for no instance, FT_Set_Named_Instance fills its copy of the design coordinates from the named instance the face still records, while the coordinates it applies become the defaults. A later request for that instance's coordinates then matches the copy, so FreeType finds nothing to change and the defaults stay in force.

Effects

The coordinates in effect apply to everything loaded afterwards: outlines, metrics, advances, bitmaps and glyph-index-advance. Glyphs already copied into glyph objects or bitmaps keep the shape they had. Setting any of these places notifies face dependents, so a cl-harfbuzz/ft2 font follows the face's variations.

Reading or setting any of these places on a face without axes signals :invalid-argument (with freetype-error-function nil), except face-variable-p, face-variation-axes and face-named-instances, which return false or empty:

DejaVu Sans is not a variable font: it has no variation axes.
Check FACE-VARIABLE-P before reading or setting variations.

Example: a weight ramp

(ft2:with-face (face lib "Inter[opsz,wght].ttf")
  (setf (ft2:face-size face) 48)
  (loop for weight from 100 to 900 by 100
        collect (progn (setf (ft2:face-axis-coordinate face "wght") weight)
                       (ft2:char-bitmap face #\g))))