Match font family names case-insensitively - #22163
Open
Gillibald wants to merge 4 commits into
Open
Conversation
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
force-pushed
the
fix/font-family-name-casing
branch
from
September 7, 2026 10:26
b8ab2aa to
15c7955
Compare
|
You can test this PR using the following package version. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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._glyphTypefaceCachegets anOrdinalIgnoreCasecomparer.SystemFontCollectioncompares the requested and platform family names case-insensitively before registering the second entry.FontManagercopiesFontManagerOptions.FontFamilyMappingsinto anOrdinalIgnoreCasedictionary.TryCreateSyntheticGlyphTypefacereuses a synthetic already cached for the same source family and key.What is the current behavior?
Every family-name comparison in
FontCollectionBaseisOrdinalIgnoreCase- the sorted family array,FontFamilyNameComparer, the binary search inTryGetGlyphTypeface,AddFontFamily's de-duplication - but the cache itself uses the default ordinal comparer, andTypeface.Normalizepreserves the author's casing. Three consequences: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.SystemFontCollectionregisters a resolved face under both the platform's family name and the requested one. When those differ only in casing that stores two buckets, whileAddFontFamilyde-duplicates case-insensitively and publishes only the first, leaving the second unreachable from every family-name search.FontFamilyMappingsentry 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,
TryCreateSyntheticGlyphTypefacesynthesises unconditionally. When a synthetic for the same source family and key is already cached, the second one losesTryAddGlyphTypefaceto the instance holding the slot, so it is handed to the caller but never cached - andGlyphTypefacehas no finalizer, only aDisposereachable 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 onIFontCollectionand the race remains.What is the updated/expected behavior with this PR?
FontFamily="arial"andFontFamily="Arial"behave identically.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:
FontManagertakes a defensive copy ofFontFamilyMappings, 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