Conformance checks for axis interaction in OpenType variable fonts.
Reads every ItemVariationStore in a font — COLR, HVAR, VVAR, MVAR, GDEF —
because a joint region in HVAR changes advance widths, and a tool that reads only COLR
would call that font free of interaction.
A variation region may peak on more than one axis. A delta stored against such a region contributes only when all of those axes are engaged together — so it is absent from every single-axis view of the font and appears only in the interior of the designspace.
This tool reports whether a given binary actually implements that, so a claim about a font can be checked against the font instead of taken on trust.
Run it on the fixtures in this repo and you get exactly this:
$ python -m colr_interaction tests/fixtures/interacting.ttf tests/fixtures/phantom.ttf
tests/fixtures/interacting.ttf
axes ['AAAA', 'BBBB'] regions 3
interaction regions on: AAAAxBBBB
PASS C1 interaction-identity holds on all rows; interaction present
PASS C2 marginal-blindness joint deltas absent from every single-axis view
PASS C3 zero-corner every variable attribute resolves to its static value at the default
PASS C4 no-phantom-axis every declared region carries at least one nonzero delta
-> carries a designed axis interaction on: AAAAxBBBB
tests/fixtures/phantom.ttf
axes ['AAAA', 'BBBB'] regions 2
PASS C1 interaction-identity holds on all rows; interaction identically zero
PASS C2 marginal-blindness joint deltas absent from every single-axis view
PASS C3 zero-corner every variable attribute resolves to its static value at the default
FAIL C4 no-phantom-axis region 1 (BBBB) declared but no row uses it
-> does not conform
The last line is generated from the bytes. Nothing in this tool lets you write it by hand.
For a variable attribute evaluated over two axes, the interaction is the second-order mixed difference:
I(a, b) = v(a, b) - v(a, 0) - v(0, b) + v(0, 0)
I ≡ 0 means the attribute is additively separable: the axes are independent and the
joint behaviour is fully predicted by the two marginals. I ≠ 0 means the joint behaviour
is not predicted by either marginal — there is something in the font that you cannot see by
moving one slider at a time.
Both are legitimate designs. This tool has no opinion about which you should ship. It reports which one you did.
Multi-axis regions have been legal since variable fonts shipped, and compilers emit them
routinely — an opsz × wght region is ordinary. What is not routine is checking them.
How thin the checking is, measured rather than asserted: at the time of writing, a code
search of fontbakery — the QA suite most font
projects run, and the one every Google Fonts submission passes through — returns zero
occurrences of VarRegionList and zero of PeakCoord. It does not inspect the
variation region model at all. fontTools implements the model (53 occurrences of
PeakCoord) but implementing a model is not checking conformance to it.
That is evidence, not proof: GitHub code search is not exhaustive and I have not audited every private toolchain in the industry. If you know of a tool that already does this, open an issue and I will link it here. The claim is meant to be falsifiable, and it would be more useful to me to be wrong early than to be wrong in public for a year.
- A font can declare an axis whose every delta row is zero. Every design application will
still enumerate
fvarand draw the user a slider for a dimension that moves nothing. That is not an internal inconsistency; it is a defect with a user interface.C4catches it. - A font can be described as having a designed interaction between two axes and ship
separable deltas.
C1settles it in one run. - A font can claim its default instance is identical to a static build.
C3checks it.
We found three phantom-axis fonts in our own tree with this, including two already shipped, and one font whose entire variation store was zeros. The check is cheap and it finds things.
C1 |
interaction-identity | For every joint-support region, the mixed difference evaluated from the store equals that region's own contribution, exactly. Evaluated at each region's own peak vector — never a fixed corner — so intermediate regions, negative peaks and 3+ axis regions are covered. For an n-axis region it is the n-th order mixed difference. |
C2 |
marginal-blindness | A joint delta contributes nothing to any single-axis evaluation — absent from the marginals, not merely small in them. |
C3 |
zero-corner | At the designspace default every region scalar is zero, so every variable value must resolve to its static value. |
C4 |
no-phantom-axis | An fvar axis that moves nothing, anywhere in the font — gvar included. That is the defect a user sees: every design application enumerates fvar and draws a slider for it. |
C5 |
reachable-deltas | Every VarIndexBase in a COLR paint resolves to a real delta set, through DeltaSetIndexMap when present. |
C6 |
base-immutability | Opt-in (--check-base). The topmost layer of each glyph's PaintColrLayers carries no variable paint. Where a font puts an interaction on translation, this is what keeps the read shape from moving. |
Findings that are not defects are reported separately as observations — an unused
region in one store, an all-zero VarData in a fixed-pitch font's HVAR, a pinned axis.
A tool that reports every oddity as a failure trains people to ignore it.
The 24 variable fonts shipped with macOS — SF Pro, SF Compact, SF Mono, New York and the script faces — are the acceptance corpus:
24 fonts · 10 carry an axis interaction · 0 failures
interacting axis sets found in the wild:
opsz x wght 10 fonts
GRAD x opsz 1
opsz x wdth 1
opsz x wdth x wght 1 <- a genuine three-way region
wdth x wght 1
Zero false positives on professionally built fonts is the bar, and reaching it took three rounds. Each round is a check that used to be wrong:
- "an all-zero
VarDatais a defect" — flagged SF Mono, SF Mono Italic and SF Camera. It is correct: a fixed-pitch font's advances do not vary with weight. - "a region no subtable uses is a defect" — flagged New York and New York Italic. Also
correct: an unused
GRADregion inHVARis expected, because a grade axis is defined not to change advance widths. - "an axis with no deltas is a phantom" — flagged SF Compact Italic, whose
opszis19/19/19. A pinned axis has no range to move along; declaring one is deliberate and standard.
All three now report as observations, and C4 fails only on the case it names.
C1–C4 run on any variable font with a variation store. C5 needs COLR. C6 assumes
the common "decorative layers under a static base" construction and is off by default.
pip install colr-interaction # once published
pip install -e . # from a clone
Requires fonttools >= 4.40.
python -m colr_interaction FONT [FONT ...]
--json machine-readable report
--explain list every interaction term with its magnitude, largest first
--top N with --explain, how many to list (default 20)
--check-base also run C6
--require-interaction exit non-zero if the font is additively separable
Exit codes: 0 conforms · 1 a check failed (or --require-interaction on a separable
font) · 2 a font could not be read.
--require-interaction is for CI on a build that claims a designed interaction. Without
it the tool still verifies the identity and reports which case the binary is, rather than
failing a font that never claimed anything.
--explain turns the verdict into an instrument. It reports how far the marginals are
from the real joint value, largest miss first:
COLR VarData[0] row 5 DEPTxTIME at DEPT=1, TIME=1
marginals predict 13599.00 actual 15074.00 miss +1475.00 +10.8%
HVAR VarData[9] row 0 opszxwght at opsz=1, wght=-1
marginals predict -1200.00 actual 0.00 miss +1200.00 +100.0%
The second row is the interesting shape: move each axis alone and you would predict −1200; the real joint value is zero, because the interaction cancels the marginals exactly.
As a library:
from colr_interaction import check_font
report = check_font("MyFont.ttf", check_base=True)
print(report.verdict, report.ok, report.interaction_axis_pairs)tests/fixtures/ is built from scratch by tests/make_fixtures.py — no third-party font
data is vendored. Three fonts: separable (two single-axis regions), interacting (adds a
joint region with a delta), phantom (declares a region no row uses, so C4 fails).
This checks the variation store, which is the part a renderer must agree on. It does not rasterize and it makes no claim about rendered pixels: rendered output is renderer-dependent (FreeType, CoreText and DirectWrite legitimately differ on subpixel antialiasing and floating-point rounding), so a rendered-output hash is a claim about a pinned environment, not a property of a font. Anything asserting otherwise is overclaiming.
It also is not a general font validator. It answers one question that existing validators do not ask.
Three classes of bug were found in this tool's own first published version, all by people and fonts outside the house that wrote it. They are listed here because the fixtures that now cover them are the most useful part of the repo:
- Delta column ≠ region index. Column i of a
VarDatarow addressesRegion[VarData.VarRegionIndex[i]], notRegion[i]. An optimized font stores only the regions a subtable uses. Both fonts this was first written against happened to have identity maps, which is exactly why it survived to publication. Fixture:shuffled-regions.ttf. - Sampling a hardcoded corner. A joint region peaking at 0.5, or peaking negative on a
bipolar axis, has scalar zero at +1 — so a corner sample reports "separable" on a font
that plainly interacts. Every region is now evaluated at its own peak vector.
Fixtures:
intermediate-peak.ttf,negative-peak.ttf. - Pairwise sampling misses n-way regions. A three-axis region held at (1, 1, 0) has
scalar zero. Fixture:
three-way.ttf.
An earlier one, kept because the lesson generalises: an unweighted sum of regions matching an axis pair gives the wrong expected value for an axis whose range is split across adjacent regions. Each region's contribution must be scalar-weighted.
Bug reports with a font that reproduces are the most useful thing, and the four fixtures above all came from someone saying "what about…". If a check is wrong, that is worth more to this repo than a feature.
MIT.