Skip to content

Add support for variable componets and for generating VARC table - #2080

Open
khaledhosny wants to merge 7 commits into
googlefonts:mainfrom
khaledhosny:varc
Open

Add support for variable componets and for generating VARC table#2080
khaledhosny wants to merge 7 commits into
googlefonts:mainfrom
khaledhosny:varc

Conversation

@khaledhosny

Copy link
Copy Markdown
Contributor

This is the plumbing for generating VARC table from variable components. No frontend currently creates variable components, but the Fontra from end will use this.

Orchestration plumbing for the upcoming VARC work.
The sparse analog of to_write_fonts_variation_region(). Needed to feed
VARC deltas into a MultiItemVariationStoreBuilder.
Gathers variable components of each composite glyph across all of its
sources and assemble them into a VARC table.

Transforms and axis values that vary across sources are run through the
variation model into a MultiItemVariationStore.
VARC is a new table and emitting it by default will produce fonts that
work only in certain environments.

A new --emit-varc-table option is added which emits VARC table for
variable components.
@khaledhosny

Copy link
Copy Markdown
Contributor Author

Part of #115 as well.

@rsheeter
rsheeter requested a review from anthrotype August 12, 2026 20:21
Comment thread fontir/src/glyph.rs
let at = variable_component_location(loc, component);
let ref_inst = get_or_instantiate_instance(&referenced, &at, context)?;
let affine = component.transform.to_affine();
for contour in ref_inst.contours.iter() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you're only copying the ref_inst.contours, what about the ref_inst.components? The base glyph referenced by a variable component can equally be a regular glyf composite. Runtime VARC consumers load those via glyf, which resolves nested components. As it stands, this would decompose to an empty glyph

Comment thread fontir/src/ir.rs
/// Decomposed transform to apply to the referenced glyph.
pub transform: DecomposedTransform,
/// If `true`, axes of the referenced glyph not present in
/// [`location`](Self::location) reset to their default; if `false` they inherit

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

axes don't "reset to their default" but to the font's current variation settings

Comment thread fontir/src/glyph.rs
component: &VariableComponent,
) -> NormalizedLocation {
let mut at = if component.reset_unspecified_axes {
NormalizedLocation::new()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when RESET_UNSPECIFIED_AXES is set "the normalized values from font's current variation settings is used."

https://github.com/harfbuzz/boring-expansion-spec/blob/main/VARC.md#processing

But NormalizedLocation::new() here resolves them to the default location (0.0).

e.g. a RESET_UNSPECIFIED_AXES component with no wght coordinate, decomposed at the bold master, would get the default weight rather than the bold one

the flattened glyf table would render differently from the compiled VARC table (with --emit-varc-table the flag is written as is and HB/fontTools apply as per spec above)

@anthrotype

Copy link
Copy Markdown
Member

I started looking at this today and will continue tomorrow, thanks for working on this!

Comment thread fontir/src/ir.rs
vertical_origin,
contours,
components,
variable_components: Vec::new(),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So IIUC any glyph instance produced by interpolation would come back with variable components emptied? (values_for_interpolation doesn't include them either, so there is nothing to interpolate them from)

Is the assumption that for the default compile path the variable components will be decomposed to contours by the time the interpolation gets to run? (the child-first variable_composite_order)

With --emit-varc-table they would still be present during glyph-order processing, where several passes there synthesize missing instances by interpolation: non-export component flattening, convert_components_to_contours, their ensure_composite_defined_at_component_locations and ensure_component_has_consistent_layers helpers, and flatten_glyph.

A variable composite's component graph can legitimately also involve ordinary components, in turn with mismatched sparse/intermediate layers somewhere in that graph that need repair through interpolation when those components must be decomposed (e.g. base is non-export, mixed contour+components, overflowing transform, decompose/flatten opt-in flags etc.).

With the current code, these glyphs would get new IR sources inserted with no variable components, which means their masters would end up disagreeing on component topology...

Comment thread fontbe/src/varc.rs
let instances: Vec<(&NormalizedLocation, &ir::VariableComponent)> = glyph
.sources()
.iter()
.filter_map(|(loc, inst)| {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unlike for ordinary components (we throw InconsistentComponents in CheckedGlyph::new when sources disagree), cross-source variable component topology is not validated.

VarcWork here emits only if the default instance has VCs. Gathering starts at the default component list and keeps another source's components only when it sits at the same index with a matching base name.

But since VariationModel::deltas allows a subset of locations, a mismatch is silently dropped with no error.

The base gid, flags and axisIndices are static per component record (variation only changes values), so mismatched sources would get misrepresented: VCs only in non-default masters omitted; a master lacking the default component (or different base at that index) dropped from the model (treated as sparse); the reset flag and axis set taken from the default alone.

I think it's better to add some validation helper to ensure same VC count in each source, and at each component index, same base, reset_unspecified_axes, axis-tag set, and set of Some transform fields.

Comment thread fontbe/src/glyphs.rs

// Variable-composite glyphs live in the VARC table, and get empty glyf
// table entries and no gvar.
if is_variable_composite(ir_glyph) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This early return discards everything else the glyph draws: a glyph whose instances mix contours or ordinary components with a variable component gets empty glyf + gvar, and the non-VC part of its drawing is silently dropped. The comment above holds only for pure variable composites.

But "impure" glyphs are not degenerate input. Sources can mix them freely (in fontra every component can potentially be variable), and so the frontends will produce contours + regular components + VC glyphs as a matter of course once VCs are wired.

There is also one coexistence the VARC spec requires preserving: when a VC references itself it does not recurse but the raw glyf outline should be loaded (as if the glyph had no VARC entry). But if the glyf+gvar gets emptied, there would be nothing to fall back to.

Rejecting these mixed VC glyphs as unsupported could be a reasonable, but given the source models above it can't be the final state. In any case, silently dropping the non-VC part of the outline is not ok.

A mechanism we could potenitally use to preserve these non-VC drawing is: the components get wrapped in variable components that behave the same as ordinary ones (no axis values, reset flag clear so they inherit current location, offsets become translate deltas); the contours stay inside glyf+gvar behind a synthesized self-reference (a new VC pointing at itself, no axis values, reset clear, identity transform).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On the synthesized self-reference, I think it only makes sense in the compiled VARC font, where a glyph's raw glyf outline is separate from how the glyph renders. In the sources, a component pointing at its own glyph is just a component cycle (a glyph drawn in terms of itself), so frontends should reject it like any other cycle. Only the compiler would create self-references, when it needs to keep the contours of mixed glyphs drawable.

FWIW the default decompose path doesn't catch that cycle today either: a glyph with contours plus a variable component pointing at itself comes out with its outline doubled (the original contours are kept, and the VC appends another copy of them).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it conceivable that an IR glyph has no VCs of its own (is_variable_composite returns false) but contains an ordinary component whose base glyph (or any of its children) is a variable composite? Probably no frontends produce this topology today, so low priority. But if that were the case, this glyf composite glyph would render blank given that we emptied the glyf entry for the variable composite and glyf isn't supposed to consult VARC.

From a font editor's point of view (especially one like fontra where all components can be variable), adding a component reference to a glyph that in turn contains other components simply means "add that whole glyph as drawn", not just point to the raw glyf outline portion (the distinction between glyf and VARC belongs to the compiler and the underlying font format, not the authoring tool).

Maybe we should extend the set of components to promote to variable component wrapper to not just the ones sharing the same glyph with VCs (my earlier suggestion above), but also any ordinary components whose base is (transitively) a variable composite.

Comment thread fontbe/src/varc.rs
rotation: t.rotation.map(|deg| deg / 180.0),
scale_x: t.scale_x,
scale_y: t.scale_y,
skew_x: t.skew_x.map(|deg| deg / 180.0),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

skewX needs its sign flipped, you need to divide by -180.

Our IR's skew_x is in fontTools' convention (clockwise degrees), but VARC skewX is counter-clockwise.
fontTools compiles it with a -180 scale, and HarfBuzz also negates skewX when applying the transform after decoding.

The same fix needed in Field::encode above for the deltas.

Rotation and skewY are fine as they are (counter-clockwise in both IR and VARC).

Comment thread fontir/src/ir.rs
Comment on lines +2132 to +2133
* skew
* Affine::scale_non_uniform(scale_x, scale_y)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The compose order doesn't match the fontTools DecomposedTransform.toTransform the docstring cites nor the VARC spec's own recipe for building the transformation matrix (https://github.com/harfbuzz/boring-expansion-spec/blob/9c795dc2f8efe0b488a5dddfebf909594a23a280/VARC.md?plain=1#L296-L304): both do rotate, scale, skew; this here does rotate, skew, scale. But scale and skew don't commute when the scale is non-uniform.

Since this to_affine is what the default compilation path bakes into the flattened contours, while the --emit-varc-table path stores the decomposed fields that consumer compose in spec order, the two paths would disagree whenever a component has both a skew and a non-uniform scale.

Comment thread fontbe/src/varc.rs
fn encode(self, value: f64) -> f64 {
match self {
Field::TranslateX | Field::TranslateY | Field::CenterX | Field::CenterY => {
(value as i16) as f64

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as i16 truncates toward zero (1.9 -> 1, -1.9 -> -1). fontTools rounds these with otRound when compiling VARC (fl2fi in VarComponent.compile, i.e. round half towards +inf, the same rule the OT spec gives for float-to-fixed conversion), and that's what the rest of fontc uses for FWORDs too (OtRound trait for glyf coordinates, metrics, GPOS values). So translate and center should use .ot_round() here for the deltas, otherwise fontc's VARC deltas could be off by one from fontTools' on the same source.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh I just noticed that comment above...

/// matching how write-fonts serializes the base transform

so your as i16 here was intentional to make fontc's conversion for the delta inputs match write-fonts' conversion for the base transform.

https://github.com/googlefonts/fontations/blob/565b4164ca58ab7b4f0e98223619948a3205c9a8/write-fonts/src/tables/varc.rs#L330

in this case I should qualify my earlier "translate and center should use .ot_round()" since otRound-ed deltas on top of a truncated base would be worse than consistent truncation.

Ideally write-fonts should use OtRound there too, but that needs a write-fonts release.

Maybe you could simply pre-round the FWORD fields with ot_round in to_write_fonts_transform so that write-fonts' cast becomes exact.

Comment thread fontir/src/glyph.rs
for (loc, instance) in original.sources().iter() {
let mut new_contours = Vec::new();
for component in instance.variable_components.iter() {
let Some(referenced) = context.try_get_glyph(component.base.clone()) else {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This warn-and-skip only covers the default (decompose) compilation path. The general preflight for this, prune_missing_components, still walks only the ordinary components, so a variable component whose base doesn't exist survives it. Here it gets skipped with a warning, but with --emit-varc-table decomposition doesn't run and the dangling VC reaches the backend, where VarcWork would fail with a bare MissingGlyphId.

So the same source builds on one path and errors on the other.

I would be simpler to prune variable_components there as well (same missing set, one warning per base), which makes this skip redundant.

Comment thread fontir/src/glyph.rs
vertical_origin: instance.vertical_origin,
contours: instance.contours.clone(),
components: Vec::with_capacity(instance.components.len()),
variable_components: instance.variable_components.clone(),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This clones the VCs through untouched, and nothing upstream looks at them either.

glyph_has_non_export_components (which decides whether this function runs at all) goes through Glyph::component_names, and that only walks the ordinary components. So a glyph which has one VC whose base is marked as non-export (and no ordinary components pointing at non-export glyphs) isn't considered one and thus left untouched. On the default compilation path this doesn't show because the decompose_all_variable_components step runs before the flatten non-export pass (variable_components is empty). But with --emit-varc-table the VC survives and the non-export glyph is then removed from the glyph order, so the VC record points at a name with no GID (MissingGlyphId again).

Ideally a non-export VC base should get the same treatment as a non-export ordinary component, i.e. inlined into the referencing glyph (which is just the decompose step for that one component).

Although it doesn't look very urgent: fontra2fontir doesn't seem to mark glyphs non-export, and it's the only frontend producing VCs AFAIK, so this can't fire today. The code path is there for whenever a Glyphs/UFO frontend grows VCs.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants