googlefonts / googlefonts/ufo2ft

SUSE-Italic GPOS kerning variation region mismatch

Open
#978 9 comments 0 reactions 0 assignees View on GitHub
Dominant language
Python
Stars
176
Forks
48
PR merge metrics
No merged PRs in 30d

Description

leaving here verbatim the output of me asking claude to investigate a diff:

# SUSE-Italic GPOS kerning variation region mismatch

## 0. Source compiled and reproduction command

**Source:** `https://github.com/SUSE/suse-font?7159afb255#sources/SUSE-Italic.glyphs`

```bash
python3 -m ttx_diff 'https://github.com/SUSE/suse-font?7159afb255#sources/SUSE-Italic.glyphs'
```

The font source has a conflict between the master's `Axis Location` custom parameter and the instance-derived axis mapping:

- **Master** "ExtraBold Italic": design=800, `Axis Location` user=800
- **Instance** "ExtraBold Italic": design=780, `Axis Location` user=800

glyphsLib builds the axis map from instances: `user=800 -> design=780`. This means the master at design=800 does NOT correspond to user=800 in the axis map. Its user-space position is ~818.18 (via `designspace.map_backward(800)`).

### HVAR/gvar path (correct)
Built by `fonttools.varLib`, which normalizes source locations from design space directly:
`normalizeValue(800, [100, 100, 1000]) = 0.7778`

### GPOS kerning path (incorrect)

**File:** `ufo2ft/Lib/ufo2ft/featureWriters/kernFeatureWriter.py`, lines 510-511
```python
location = VariableScalarLocation(
get_userspace_location(designspace, source.location)
)
```

**File:** `ufo2ft/Lib/ufo2ft/util.py`, lines 663-666
```python
def get_userspace_location(designspace, location):
location_user = designspace.map_backward(location)
return {designspace.getAxis(k).tag: v for k, v in location_user.items()}
```

This maps the master's design location (800) backward through the axis map to get user=818.18. This user-space location is then passed to `VariableScalar`.

**File:** `fonttools/Lib/fontTools/feaLib/variableScalar.py`, lines 164-178
```python
def _normalize_location(self, location):
for axtag, value in location:
axis_min, axis_default, axis_max = self.axis_triples[axtag]
normalized = normalizeValue(value, (axis_min, axis_default, axis_max))
mapping = self.axis_mappings.get(axtag)
if mapping is not None:
normalized = piecewiseLinearMap(normalized, mapping)
result[axtag] = normalized
```

This normalizes user=818.18 to 0.7980, then applies the avar mapping: `0.7980 -> 0.8000`. This 0.8 value ends up as the GDEF VarStore region peak.

The axis map inversion is also lossy due to the non-invertible mapping (both user=900 and user=1000 map to design=1000, but the `{d: u for u, d in map}` dict only keeps the last one).

## 2. Root cause

The fontmake pipeline uses **two different normalization paths** for the same master position:

1. **varLib** (for HVAR, gvar): design=800 -> normalize directly -> 0.7778
2. **ufo2ft kern writer + feaLib** (for GPOS kerning): design=800 -> `map_backward` -> user=818.18 -> normalize -> 0.7980 -> avar -> 0.8000

These produce different normalized coordinates (0.7778 vs 0.8) for the same master, resulting in **internally inconsistent VarStore regions** within the same font. The GDEF VarStore (used by GPOS kerning) has different region peaks than the HVAR VarStore, even though they describe the same variation space.

The root cause is triggered by a conflict in the font source: the master's `Axis Location` says user=800 -> design=800, but the instance-derived axis map says user=800 -> design=780. The `map_backward()` inversion of this conflicting map produces user=818.18 for design=800, which after normalization and avar mapping gives 0.8 instead of 0.7778.

## 3. Which side is wrong

**fontmake is wrong.** fontc is correct.

fontmake produces an internally inconsistent font: the GDEF VarStore (backing GPOS kerning) uses region peak 0.8 while the HVAR VarStore and gvar use region peak 0.7778 for the same master. All variation data in a font should use the same coordinate system. At runtime, the rasterizer computes a single set of coordinates and applies them uniformly to all variation data; having different regions means kerning deltas are evaluated at a different point than glyph outline and advance width deltas, producing incorrect kerning at non-master positions.

fontc consistently uses 0.7778 (the design-to-normalized value) for all VarStores, which is correct and matches what varLib produces for HVAR/gvar.

## 4. Suggested fix

This is a **fontmake/ufo2ft bug**, not a fontc bug. No fix is needed in fontc.

The fix in ufo2ft would be to avoid the design -> user -> normalize -> avar round-trip in `kernFeatureWriter.py`. Instead of:
```python
location = VariableScalarLocation(
get_userspace_location(designspace, source.location)
)
```

The kern feature writer should either:
1. Use design-space locations and normalize them the same way varLib does (matching the HVAR/gvar path), or
2. Use the source's actual user-space location from the master's `Axis Location` parameter instead of computing it via `map_backward()`.

Alternatively, the fix could be in `fonttools/feaLib/variableScalar.py`'s `VariableScalarBuilder` to accept design-space locations directly and normalize them without the avar round-trip.

Contributor guide

No contributing guide indexed for this repository

Research direction

Start with ufo2ft/Lib/ufo2ft/featureWriters/kernFeatureWriter.py and ufo2ft/Lib/ufo2ft/util.py, then reproduce the issue with the provided ttx_diff command. Trace the kerning location through feaLib variableScalar.py and compare the resulting GDEF regions with HVAR/gvar coordinates. Done means the same master uses consistent normalized coordinates across the variation stores.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
build-system, tooling
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
55/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.