Skip to content

Move anchor propagation from glyphs-reader to fontir#1832

Merged
anthrotype merged 12 commits intomainfrom
propagate-anchors-in-ir
Feb 12, 2026
Merged

Move anchor propagation from glyphs-reader to fontir#1832
anthrotype merged 12 commits intomainfrom
propagate-anchors-in-ir

Conversation

@anthrotype
Copy link
Copy Markdown
Member

@anthrotype anthrotype commented Dec 12, 2025

Fixes #1704

This huge PR refactors anchor propagation to be source-agnostic by moving it from glyphs-reader (Glyphs-specific preprocessing) to fontir so that it works with not only for Glyphs sources but also for DS + UFOs or any other frontends.

GoogleSans.designspace markkern.txt goes from the current 82.760% to 97.262% (the remaining diffs are bugs on ufo2ft propagateAnchors filter's side which I'll fix separately).

The main problem when moving the implementation from glyphs-reader to fontir was that there was a circular dependency in the way GDEF categories are computed for Glyphs sources in particular, whereby on the one hand the anchor propagation algorithm needs mark/ligature information, but on the other hand some GDEF glyph categories (Base, Ligature) are inferred based on the presence of anchors and thus need anchors to be propagated first.
In the previous code on main, this wasn't a problem because glyphs-reader would use the glyph category/subCategory fields to do its propagate_anchors.rs, which would be performed at the very beginning when the Font was first loaded, so that by the time the GDEF categories were computed all the anchors (including propagated) would already be there.

To resolve the circularity, I resorted to a two-phase approach similar to the one we use for PreliminaryGlyphOrder vs GlyphOrder: i.e. first each frontends populate PreliminaryGdefCategories using only source metadata (such as Glyphs' category/subCategory or the UFO public.openTypeCategories) but without checking the anchors; then after anchor propagation is done inside fontir, the final GdefCategories are computed by also taking the anchors into account.
Note that for DS+UFOs workflow, the public.openTypeCategories are presumed to already be "final" and are not extented/pruned based on anchors (at least that's how ufo2ft treats these), so in fontc I follow this (source frontends can set a bool field on the PreliminaryGdefCategory struct to control whether final GdefCategories shoud be inferred from anchors or not).

I kept the current source-level defaults: i.e. for .glyphs source we run anchor propagation by default unless disabled via either "Propagate Anchors" (false) custom parameter (as well as a "propagateAnchors" ufo2ft filter hidden in the masters' userData which is a remnant of ufo2glyphs workflows); whereas for DS + UFO sources anchor propagation is disabled by default but it can be enabled via "propagateAnchors" filter in "com.github.googlei18n.ufo2ft.filters" lib key (like GoogleSans does). And in any case, a tri-state CLI flag --propagate-anchors={true,false} can be used to override these (omitted means follow source).

I ported all the tests from the previous implementation, while adding a few more, and deleted the glyphs-reader/src/propagate_anchors.rs.

I know it's a big change, and I tried to split into logical commits that make review easier as much as possible, but it's inevitable that a lot of modules are affected. I suggest to review single commits in the order the appear.

Last thing, #1661 is not implemented here yet but this should make it easier to implement since we are already interpolating glyph geometry in fontir (for decomposing and flattening) so adding anchor interpolation should be relatively simple.

EDIT: I also included in here a fix for propagating cursive anchors with suffixes from googlefonts/glyphsLib#1130

Comment thread fontir/src/glyph.rs Outdated
@anthrotype anthrotype force-pushed the propagate-anchors-in-ir branch 2 times, most recently from 3b425b0 to c774e66 Compare December 17, 2025 11:44
Copy link
Copy Markdown
Member

@cmyr cmyr left a comment

Choose a reason for hiding this comment

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

no major notes on the impl, but this is causing a bunch of regressions for existing fonts.

Some examples of previously identical sources that are now producing diffs:

python3 -m ttx_diff 'https://github.com/mara-aa/elms-sans?bc5a52f349#sources/ElmsSans.glyphs'
python3 -m ttx_diff 'https://github.com/positype/Murecho-Project?0efba44c1c#sources/Murecho.glyphs'
python3 -m ttx_diff 'https://github.com/cyrealtype/Lora-Cyrillic?c44a1dde19#sources/Lora.glyphs'
python3 -m ttx_diff 'https://github.com/reddit/redditsans?60e19b50bd#sources/RedditMono.glyphs'
python3 -m ttx_diff 'https://github.com/solmatas/BitterPro?3238d7ae2c#sources/Bitter.glyphs'

Comment thread fontbe/src/features/kern.rs Outdated
Comment thread fontir/src/ir.rs Outdated
Comment thread ufo2fontir/src/toir.rs
@anthrotype
Copy link
Copy Markdown
Member Author

Thanks for taking a look, I'll fix the regressions tomorrow!

Comment thread fontc/src/args.rs
flags.set(Flags::ERASE_OPEN_CORNERS, true);
}
// TODO: add PROPAGATE_ANCHORS flag when we have that implemented
if self.propagate_anchors == Some(true) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Not actionable, just looking to learn.

In practice, how often do these flags change? Will user's want to experiment with propagate_anchors for the foreseeable future or is it a mechanism to roll out a feature that basically every user will leave alone.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

it's mostly to be able to test the effect of anchor propagation (or its absence) without needing to modify the source itself. I'd expect users to prefer enabling/disabling this in the source via appropriate custom parameter (or ufo lib key).

// If set, open corners will be erased (Glyphs-native feature)
const ERASE_OPEN_CORNERS = 0b1000000000;
// If set, anchors will be propagated from components to composites
const PROPAGATE_ANCHORS = 0b10000000000;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I was confused for a second and thought these were all the same! What do you think of separating bytes with underscore?

0b100_00000000

Comment thread fontra2fontir/src/toir.rs
Default::default(), // TODO: glyph locations we really do need
Default::default(),
Default::default(),
Default::default(),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is used to build the StaticMetadata for fontra2fontir/src/source.rs. In that file, also_completes has not been updated. Does it get generated somewhere else for fontra or does fontra not need GDef categories?

StaticMetadataWork for fontir

impl Work<Context, WorkId, Error> for StaticMetadataWork {
    fn id(&self) -> WorkId {
        WorkId::StaticMetadata
    }

    fn also_completes(&self) -> Vec<WorkId> {
        vec![WorkId::PreliminaryGlyphOrder] // No PreliminaryGdef categories
    }

    fn exec(&self, context: &Context) -> Result<(), Error> {
        debug!("Static metadata for {:#?}", self.fontdata_file);
        context
            .preliminary_glyph_order
            .set(self.glyph_info.keys().cloned().collect());
        context
            .static_metadata
            .set(create_static_metadata(&self.fontdata_file)?);
        Ok(())
    }
}

Compute GDEF categories in two phases to break the circular dependency
between anchor propagation (needs mark/ligature info) and category
computation (needs to see propagated anchors for Base detection).

1. PreliminaryGdefCategories - from source metadata only, no anchor
   inspection. Used during anchor propagation.

2. GdefCategories - final categories after propagation. For glyphsLib
   sources (infer_from_anchors=true), Base is inferred from anchors.
   For UFO sources (infer_from_anchors=false), categories used as-is.

Source loaders now compute preliminary categories and store them in
context.preliminary_gdef_categories (no longer in StaticMetadata).
Final categories are computed later by GlyphOrderWork after anchor
propagation completes.
Add a new tri-state compilation flag to control anchor propagation.

Enabled by default for Glyphs sources (native behavior), or optionally enabled for
UFO sources when propagateAnchors filter is present.

Glyphs sources can opt-out via regular "Propagate Anchors" custom parameter,
as well as via ufo2ft propagateAnchors filter lib key in userData (for .glyphs
sources converted by ufo2glyhs).

This follows the pattern of existing flags like FLATTEN_COMPONENTS
and ERASE_OPEN_CORNERS.
This is the source-agnostic equivalent of what glyphs-reader previously did
as preprocessing.

One missing thing is support for Glyphs.app-specific component.anchor,
which I'll tackle in a separate commit.
If PROPAGATE_ANCHORS flag is set, call propagate_all_anchors() to copy
anchors from components to composites

Always call recompute_gdef_categories() to compute final categories:
- For glyphsLib (infer_from_anchors=true): infer Base from anchors,
  require anchors for Ligature
- For ufo2ft (infer_from_anchors=false): copy preliminary as-is

The recompute_gdef_categories() function:
- Takes preliminary categories (Mark/Ligature/Base from source)
- Optionally inspects anchors to infer Base and prune Ligatures (matching glyphsLib)
- Writes final categories to context.gdef_categories

Added test for recompute_gdef_categories to verify that glyphs with
pre-existing anchors get classified as Base even when anchor
propagation is disabled.
fontbe now reads gdef_categories from context.ir.gdef_categories
instead of static_metadata.gdef_categories.

Also update fontc job scheduling to include GdefCategories.

Removed the old glyphs-reader implementation, as the migration to fontir
should now be complete.

Moved the `CompositeLike` impl for `glyphs_reader::font::Glyph` to
glyphs-reader/font.rs since it's still needed by `align_bracket_layers()`.

Also, moved some integration tests from the deleted propagate_anchors.rs
module to fontc/src/lib.rs tests, adapted to work on IR.
Implement the same `maybe_rename_component_anchor` logic which was previously
in glyphs-reader's propagate_anchors.rs.

For that I needed to add a new `anchor` field to `ir::Component` to store the
explicit component attachment anchors. When a base has multiple anchors with the
same prefix (e.g., `top`, `top_1`, `top_alt`), this specifies which one
a mark component attaches to (e.g. ligature numbered anchors `top_2`
for second letter of `f_i`, or alternative anchors `top_alt` for
Vietnamese diacritics).

In addition to preserving `component.anchor` from .glyphs sources in glyphs2fontir,
I also parse `com.schriftgestaltung.Glyphs.ComponentInfo` in ufo2fontir from a UFO
glyph's lib, for sources that were exported to DS+UFO by glyphsLib.
- [kern.rs] Move GdefCategories import in with other imports
- Make Component::new() and with_anchor() accept impl Into<GlyphName> and simplify call sites
Two issues were preventing anchors from propagating correctly when
composites reference non-exporting glyphs:

1. Anchors were only collected for exporting glyphs. Non-exporting
   glyphs with anchors (used as components) weren't having their
   anchors stored in the IR, so propagation couldn't find them.

2. flatten_all_non_export_components() ran BEFORE propagate_all_anchors().
   This converts component references to paths, so by the time
   propagation ran, the composite no longer knew it referenced a
   non-exporting component.

The fix involves collecting anchors from ALL glyphs (with graceful error
handling for invalid anchors on non-exporting glyphs per issue #1397), and
reordering the operations so propagation happens while component references
still exist.

Fixes regressions in ElmsSans and Bitter (now identical), and RedditMono's GDEF
There are still a bunch of other diffs which I'm currently investigating.
@anthrotype anthrotype force-pushed the propagate-anchors-in-ir branch from e8d2754 to d186834 Compare February 11, 2026 11:26
Cursive anchors begin with "entry"/"exit", not end with it. Using
ends_with() works for bare "entry"/"exit" names but fails for suffixed
names like "entry.2" or "exit.alt".

Port of googlefonts/glyphsLib#1130
After b89c057 ("Remove or annotate all unwraps and slices"),
DesignLocation::to_normalized() returns Result instead of
NormalizedLocation directly.
@anthrotype anthrotype force-pushed the propagate-anchors-in-ir branch from d186834 to aa0bd56 Compare February 11, 2026 11:36
@anthrotype
Copy link
Copy Markdown
Member Author

I think this is ready for merge now. I've reviewed the 5 regressions that Colin reported back in December:

The "cedilla" is a composite glyph referencing "cedillacomb" as a component. In Glyphs.app's GlyphData.xml, "cedilla" has category=Mark, subcategory=Spacing (which is a bit of a misnomer since these are not combining marks, the Unicode category is "Sk" modifier symbol). Glyphs.app skips anchor propagation for category=Mark composite glyphs that already have their own anchors, it keeps them as-is without looking at components. This means cedilla (which happens to have "_bottom" in Lora) keeps only that anchor and doesn't inherit "bottom" from cedillacomb. As a result, fontmake doesn't classify it as GDEF Base, and doesn't add MarkToBase for "cedilla".
fontc only skips propagation for GDEF Mark glyphs, which are defined as Glyphs category=Mark and subCategory=Nonspacing or SpacingCombining. Since Mark/Spacing glyphs like cedilla are not GDEF Mark (they're spacing glyphs that shouldn't act as marks in GPOS), fontc propagates cedillacomb's bottom anchor into cedilla, which causes it to be inferred as GDEF Base and generates one extra MarkToBase rule.

I think fontc's behavior is defensible here:

  • cedilla is not a combining mark (Unicode Sk, not Mn/Mc)
  • Mark/Spacing glyphs are not marks in the OpenType sense (not GDEF Mark class)
  • The _bottom anchor on cedilla is arguably a font bug, a non-combining spacing glyph shouldn't need a mark attachment anchor
  • Glyphs.app's blanket category=Mark skip is an implementation shortcut, not a principled rule.

I considered these alternatives to match fontmake for Lora, none of which i'm 100% happy with:

  1. Add something like marks_for_propagation: BTreeSet<GlyphName> to PreliminaryGdefCategories to track all source-level category=Mark glyphs (regardless of subcategory) and use that set alongside the GDEF Mark check when deciding whether to skip propagation. This works (verified Lora identical) but leaks a Glyphs-specific concept into the format-agnostic IR layer (would default to empty for UFO sources, which don't have this distinction).
  2. Change glyphsLib to match fontc: only skip propagation for true marks (Mark/Nonspacing and Mark/SpacingCombining), not Mark/Spacing. This would align with Unicode and OpenType definition of a mark, but also deviate from Glyphs.app which is supposed to be the reference implementation for .glyphs sources (I verified that "bottom" is not propagated to "cedilla" in Glyphs.app).
  3. Use some heuristic to treat glyphs containing underscore-prefixed anchors as mark-like. Simple change, but conflates a coincidental signal (presence of underscore anchors) with the actual source-level Mark category. It would work with this particular diff in Lora's cedilla, but it may have unwanted side effects.

I'm tempted to merge this PR now and file an issue about Lora to fix later.

When a composite glyph has an intermediate (brace) layer but its
component doesn't, interpolate the component's anchor positions from
its available locations using a VariationModel.

Previously, missing component anchors at a location caused the component
to be skipped entirely, letting the base glyph's anchors leak through.
For RedditMono's aeacute, this caused a 405-unit error in GPOS mark
positioning data.

VariationModels are cached by location set to avoid rebuilding them
for each (component, location) miss (most components share the same
master locations).

Fixes #1661
Copy link
Copy Markdown
Member

@cmyr cmyr left a comment

Choose a reason for hiding this comment

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

okay happy to checkpoint this and then go from there :)

@anthrotype anthrotype enabled auto-merge February 12, 2026 15:08
@anthrotype anthrotype disabled auto-merge February 12, 2026 15:14
@anthrotype anthrotype added this pull request to the merge queue Feb 12, 2026
Merged via the queue into main with commit 8023801 Feb 12, 2026
13 checks passed
@anthrotype anthrotype deleted the propagate-anchors-in-ir branch February 12, 2026 15:14
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.

should optionally propagate anchors for designspace+UFOs

3 participants