googlefonts / googlefonts/fontc
[propagate_anchors] incorrectly assumes base component references map 1:1 to ligature members
- Dominant language
- Rust
- Stars
- 193
- Forks
- 21
- Avg merge
- 1d 20h
- Merged PRs (30d)
- 60
Description
All three anchor propagation implementations (or two if you like, fontc and glyphsLib, on the one hand - derived the same Glyphs.app's source snippet - and ufo2ft's PropagateAnchorsFilter, a reverse engineering that predates the former) infer ligature component positions from component references. In other words, they assumes a 1:1 mapping between component references and ligature members, which doesn't hold in general.
This assumption breaks for ligature glyphs that mix inline contours and component references, or those where one ligature member is split into multiple component references: either way, the number of component references may not equal the number of ligature components, and the positional mapping between the two is lost/undefined.
`propagate_anchors` numbers anchors by iterating over component references, producing indices that don't match the ligature member positions tracked by the shaping engine (which knows the true member count from GSUB). E.g. if component references are at positions 2 and 3 of a 3-part ligature (position 1 being a contour), the anchors would still numbered `top_1`, `top_2` instead of `top_2`, `top_3`
fontc (and glyphsLib and Glyphs.app) counts "base" components (one that has at least one alphabetic-prefixed "base" anchor like "top", no _-prefixed anchors like "_top") and uses the running count to assign ligature anchor indices. This works when each ligature member is exactly one base component. It breaks when a ligature member is built from multiple non-base sub-parts or drawn as a contour (which contribute 0 to the count): the numbering still starts from 1 at the first base component, regardless of which ligature member it actually is, and even when there is only one such base component.
And the numbered anchors are later used to generate MarkToLig lookups, where the anchor index must correspond to the respective ligature component index in the writing/logical direction of the text (cf. https://learn.microsoft.com/en-us/typography/opentype/spec/gpos#MLP).
You may argue that's ok if they are all broken in a similar way, but the problem is that in some specific cases the two font compilers are broken in different ways and produce ttx diffs on fontc crater. Most notably with GoogleSans.designspace, which is not a .glyphs source but uses ufo2ft's PropagateAnchors filter:
```
python3 -m ttx_diff 'https://github.com/googlefonts/googlesans?fce0e0668e#sources/../source/GoogleSans/GoogleSans.designspace'
```
fontc emits 8 MarkLigPos (GPOS LookupType 5) lookups vs fontmake's 7, with 65 vs 29 MarkToLig rules total.
The diffs arise for ligatures in which only **one** component contributes a given anchor, because:
- fontc unconditionally renames a single propagated anchor `top` to `top_1` when the parent is a ligature, thus generates MarkToLig rules (with potentially wrong index)
- ufo2ft only renames when >= 2 components share the same anchor name ([`_get_anchor_data` line 170](https://github.com/googlefonts/ufo2ft/blob/4a9766d430374b1814029e1a6557bfab713eaeac/Lib/ufo2ft/filters/propagateAnchors.py#L170)), so a single component's anchor stays plain `top` (not numbered `top_1` anchor), thus no MarkToLig rule is generated for the same glyphs.
Examples from GoogleSans:
- **`f_b`** (GDEF ligature): 4 component refs (3 structural `f` sub-parts + `b`), only `b` has `top`. fontc renames to `top_1` → MarkToLig. fontmake: plain `top` → no MarkToLig.
- **`sso_aaSign-khmer.post2_`** (GDEF ligature): 1 contour + 1 component (`sso-khmer.post2`), component has `top`. The composite also has an explicit `top` anchor. fontc renames component's `top` to `top_1` (no collision with explicit `top`) → MarkToLig. fontmake: no renaming → component's `top` collides with explicit `top`, explicit wins → no MarkToLig.
In an ideal world, we would not propagate ligature-indexed anchors when the component-to-ligature-member mapping is ambiguous. The truth is the algorithm cannot reliably determine which ligature member a component reference belongs to: counting components doesn't work (sub-component decomposition), counting contours doesn't work (one member may have multiple contours), and the true member count is only known from GSUB, which `propagate_anchors` doesn't have access to.
`caret_N` anchors could serve as a heuristic signal (N carets = N+1 ligature members), but they may not always be present. When they are, a mismatch between `max(caret index) + 1` and the number of components contributing an anchor would confirm ambiguity.
More practically, we might want to accept that "this is how Glyphs.app, our reference implementation for anchor propagation, behaves" (even if it's technically incorrect in some situations), and the outlier is ufo2ft. We'd just accept the diff and do nothing, or maybe fix ufo2ft to match glyphsLib...
Contributor guide
No contributing guide indexed for this repository
Assessment
This issue has not been assessed yet.