Skip to content

fontc drops point axes with non-identity user-to-design mappings #1990

@cmyr

Description

@cmyr

cc @anthrotype: this is claude's reasoning about a particular diff in Unna.glyphs, but I don't feel like I fully understand the issue. Specifically, it isn't obvious to me why it is useful to had a wdth axis at all in this font, if it isn't variable? Fontmake is adding an extra axis, which has some binary and complexity costs, and that axis isn't doing anything. Is that helpful?

claude's output below, which is assuming that fontmake is right:

Reproduction

python3 -m ttx_diff 'https://github.com/Omnibus-Type/Unna?826be2abeb#sources/Unna.glyphs'

Affected tables: fvar, STAT, avar, HVAR, MVAR, name

Summary

When a Glyphs source has an axis where all masters share the same design-space position but a non-identity user-to-design mapping exists, fontc drops the axis entirely. fontmake correctly emits it as a point axis with the mapped user-space coordinates.

Details

The Unna font is a Glyphs v2 source with 2 masters (Regular and Bold). Both masters sit at widthValue=5. The font has no explicit "Axes" custom parameter, so it gets the default axes (wght, wdth, custom).

Instance widthClass values produce a non-identity wdth mapping: user 100 → design 5. Despite there being no variation along this axis, the mapping is semantically meaningful — it defines the user-space coordinate system. The correct output is a point axis in fvar with min=100, default=100, max=100 and an avar mapping entry (100, 5).

fontc omits the axis completely.

fontc code path

In glyphs2fontir/src/toir.rs:203-205, the has_non_identity_mapping check has three conditions:

let has_non_identity_mapping = font.axis_mappings.contains(&axis.name)
    && !font.axis_mappings.get(&axis.name).unwrap().is_identity()
    && min != max;

For wdth, both masters have widthValue=5, so min == max == DesignCoord(5). The third condition min != max is false, making the entire expression false. The code falls through to the unmapped branch (line 220-225):

let min = UserCoord::new(min.into_inner());   // 5.0
let max = UserCoord::new(max.into_inner());   // 5.0
let default = UserCoord::new(default.into_inner()); // 5.0
CoordConverter::unmapped(min, default, max)

The axis becomes min=5, default=5, max=5 in user space (raw design units, not the CSS percentage scale). Then in fontir/src/ir/static_metadata.rs:400:

let variable_axes: Axes = axes.iter().filter(|a| !a.is_point()).cloned().collect();

This filters out all point axes. The wdth axis is gone from fvar, STAT, avar, and name.

fontmake code path

In glyphsLib/builder/axes.py:288-293, the axis inclusion condition is:

if (
    minimum < maximum
    or minimum != axis_def.default_user_loc
    or not is_identity_map
    or axis_wanted
):

The not is_identity_map condition catches this case. The mapping {100: 5} is non-identity, so the axis IS included despite minimum == maximum. The axis is emitted with min=100, default=100, max=100 and a mapping entry (100, 5).

Why the min != max guard is wrong

The comment above the check (lines 199-202) explains the intent:

// If all masters sit at the same position on this axis, the mapping is
// meaningless and there's no variation to map. Treat as unmapped.

This conflates two distinct purposes of the axis mapping:

  1. Mapping variation deltas between coordinate systems — this is indeed meaningless when there's no variation.
  2. Defining the user-space coordinate system for fvar/STAT — this is meaningful regardless of variation. A point axis at wdth=100 (CSS percentage) is semantically different from one at wdth=5 (internal design units).

Downstream effects

Table fontc fontmake
fvar 1 axis (wght) 2 axes (wght, wdth)
STAT DesignAxisCount=1 DesignAxisCount=2
avar no wdth segment wdth identity segment
HVAR/MVAR RegionAxisCount=1 RegionAxisCount=2
name no "Width" axis name has "Width" axis name

Metadata

Metadata

Assignees

No one assigned

    Labels

    craterIssues based on a crater diff

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions