Skip to content

Match font family names case-insensitively - #22163

Open
Gillibald wants to merge 4 commits into
mainfrom
fix/font-family-name-casing
Open

Match font family names case-insensitively#22163
Gillibald wants to merge 4 commits into
mainfrom
fix/font-family-name-casing

Conversation

@Gillibald

Copy link
Copy Markdown
Contributor

What does the pull request do?

Follow-up to #21993. Family names are matched case-insensitively everywhere in the font stack except the two places that decide which typeface you actually get, so the same family resolves differently depending on how it was capitalised. Also closes the synthesis leak that #21993 made unreachable on the hot path but did not remove.

  • FontCollectionBase._glyphTypefaceCache gets an OrdinalIgnoreCase comparer.
  • SystemFontCollection compares the requested and platform family names case-insensitively before registering the second entry.
  • FontManager copies FontManagerOptions.FontFamilyMappings into an OrdinalIgnoreCase dictionary.
  • TryCreateSyntheticGlyphTypeface reuses a synthetic already cached for the same source family and key.

What is the current behavior?

Every family-name comparison in FontCollectionBase is OrdinalIgnoreCase - the sorted family array, FontFamilyNameComparer, the binary search in TryGetGlyphTypeface, AddFontFamily's de-duplication - but the cache itself uses the default ordinal comparer, and Typeface.Normalize preserves the author's casing. Three consequences:

  • A request whose casing differs from the cached one misses the bucket and skips the nearest-match branch entirely. The family-name search that picks it up afterwards discards isNearestMatch, so it returns the nearest match raw, without synthesis and without caching. "MyAlias" at Black renders a synthesised bold while "MYALIAS" at Black renders regular weight, and the platform call repeats on every request for the mis-cased name.
  • SystemFontCollection registers a resolved face under both the platform's family name and the requested one. When those differ only in casing that stores two buckets, while AddFontFamily de-duplicates case-insensitively and publishes only the first, leaving the second unreachable from every family-name search.
  • A configured FontFamilyMappings entry silently does not apply when the requested name differs in casing from the configured key, since the dictionary carries whatever comparer its author gave it.

Separately, TryCreateSyntheticGlyphTypeface synthesises unconditionally. When a synthetic for the same source family and key is already cached, the second one loses TryAddGlyphTypeface to the instance holding the slot, so it is handed to the caller but never cached - and GlyphTypeface has no finalizer, only a Dispose reachable from the cache, so its native typeface stays alive for the rest of the process. Each one costs a full copy of the font file. #21993 removed the path that reached this on every call, but the method is public on IFontCollection and the race remains.

What is the updated/expected behavior with this PR?

  • A family name resolves to the same glyph typeface whatever its casing, so a mis-cased name can no longer render at the wrong weight. FontFamily="arial" and FontFamily="Arial" behave identically.
  • A configured font family mapping applies whatever casing the requested name arrives in.
  • Repeated synthesis for an already-synthesised key returns the cached instance instead of leaking a native typeface per call.

To see the first one, resolve a family through a platform alias at a weight the device cannot match, then ask again with different capitalisation: both now come back as the same synthesised face.

Checklist

Breaking changes

None to the public API. Three behavioural changes worth naming:

  • A family name now resolves to the same typeface whatever its casing. Code that (deliberately or not) relied on two casings resolving to different faces would change behaviour.
  • A font family mapping now applies whatever casing the requested name arrives in.
  • FontManager takes a defensive copy of FontFamilyMappings, so it no longer observes mutations made to the caller's dictionary after construction. Mapping keys colliding only by casing were separate entries before the copy; the last wins rather than throwing, since a mapping table is configuration and failing there would take the application down at startup.

Obsoletions / Deprecations

None.

Fixed issues

Follows up #21993.

🤖 Generated with Claude Code

Keep the invariant and the reason, drop the retelling of how the leak was
found. Also plain-ASCII the dashes and correct the doc on the test counter:
the alias resolution goes through the same overload, so the counter proves
the cached result short-circuits the platform call too.
Every other family-name comparison in FontCollectionBase is
OrdinalIgnoreCase - the sorted family array, the binary search in
TryGetGlyphTypeface, AddFontFamily's de-duplication - but the cache itself
used the default ordinal comparer. Two consequences:

- A request whose casing differs from the cached one misses the bucket and
  so skips the nearest-match branch entirely. The family-name search that
  picks it up afterwards returns the nearest match raw, without synthesis
  and without caching, so "MyAlias" at Black renders faux-bold while
  "MYALIAS" at Black renders regular weight.
- SystemFontCollection registers a resolved face under both the platform's
  family name and the requested one. When those differ only in casing that
  stores two buckets, while AddFontFamily de-duplicates case-insensitively
  and publishes only the first, leaving the second unreachable from every
  family-name search.

Also compares the requested and platform family names case-insensitively
before the second registration, so the redundant add goes away rather than
becoming a silent no-op.
TryCreateSyntheticGlyphTypeface synthesised unconditionally. When a synthetic
for the same source family and key was already cached, the second one lost
TryAddGlyphTypeface to the instance holding the slot, so it was handed to the
caller but never cached - and GlyphTypeface has no finalizer, only an explicit
Dispose reachable from the cache, so its native typeface stayed alive for the
rest of the process. Each one costs a full copy of the font file, which
TryGetStream reads into memory and SKTypeface.FromStream copies again.

Return the cached synthetic instead when its simulations match the ones this
call would apply. The source family's typefaces are already in hand for the
early-out above; they were fetched and then never used.

Caching under the requested family name removed the path that reached this on
every call, but the method is public on IFontCollection and the race remains,
so guard it at the source.
FontManagerOptions.FontFamilyMappings is supplied by the application, so its
comparer is whatever its author gave it - ordinal for a plain
Dictionary<string, FontFamily>. Family names are matched case-insensitively
everywhere else, so a mapping silently did not apply when the requested name
differed in casing from the configured key.

Copy the mappings into an OrdinalIgnoreCase dictionary in the constructor.
Keys that collide only by casing were separate entries before; the last wins
rather than throwing, since a mapping table is configuration and failing here
would take the application down at startup.

Two behaviour changes worth naming: a mapping now applies whatever casing the
name arrives in, and FontManager no longer observes mutations made to the
caller's dictionary after construction.
@Gillibald
Gillibald force-pushed the fix/font-family-name-casing branch from b8ab2aa to 15c7955 Compare September 7, 2026 10:26
@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 12.2.999-cibuild0069524-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@MrJul MrJul added bug area-textprocessing backport-candidate-12.1.x Consider this PR for backporting to 12.1 branch labels Sep 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-textprocessing backport-candidate-12.1.x Consider this PR for backporting to 12.1 branch bug

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants