From 12fda66eb79b4422868d717bf37938ce665bab15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Lucas=20Golini?= Date: Sun, 16 Aug 2026 01:30:18 -0300 Subject: [PATCH] Fix CoreText system font family and face resolution Require the requested family when matching CoreText descriptors to prevent substitutions such as Verdana resolving to Helvetica. Validate the matched descriptor family and derive TTC face indices by matching CoreText PostScript names against the font's FreeType faces instead of using the descriptor array position. Add macOS regression coverage for resolving and loading Verdana styles. --- src/eepp/graphics/systemfontresolver.cpp | 63 +++++++++++++++++-- .../unit_tests/systemfontresolver_tests.cpp | 18 ++++++ 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/src/eepp/graphics/systemfontresolver.cpp b/src/eepp/graphics/systemfontresolver.cpp index 0c73ba0ad..12b95b51b 100644 --- a/src/eepp/graphics/systemfontresolver.cpp +++ b/src/eepp/graphics/systemfontresolver.cpp @@ -137,6 +137,35 @@ struct FreeTypeState { return found; } + bool findFaceIndex( const std::string& path, const std::string& postScriptName, + EE::Uint32& faceIndex ) { + Lock lock( mutex ); + FT_Face face{ nullptr }; + if ( !library || FT_New_Face( library, path.c_str(), -1, &face ) != 0 ) + return false; + + const FT_Long numFaces = face->num_faces; + FT_Done_Face( face ); + if ( numFaces == 1 ) { + faceIndex = 0; + return true; + } + if ( postScriptName.empty() ) + return false; + for ( FT_Long i = 0; i < numFaces; ++i ) { + if ( FT_New_Face( library, path.c_str(), i, &face ) != 0 ) + continue; + const char* facePostScriptName = FT_Get_Postscript_Name( face ); + const bool matches = facePostScriptName && postScriptName == facePostScriptName; + FT_Done_Face( face ); + if ( matches ) { + faceIndex = static_cast( i ); + return true; + } + } + return false; + } + void clearProbeCache() { Lock lock( mutex ); probeCache.clear(); @@ -777,6 +806,11 @@ void SystemFontResolver::populateFontList() const { return; CFIndex count = CFArrayGetCount( descriptors ); + std::shared_ptr ftState = getFTState(); + if ( !ftState ) { + CFRelease( descriptors ); + return; + } for ( CFIndex i = 0; i < count; ++i ) { CFStringRef familyNameRef = (CFStringRef)CFArrayGetValueAtIndex( descriptors, i ); @@ -792,8 +826,12 @@ void SystemFontResolver::populateFontList() const { CTFontDescriptorRef familyDesc = CTFontDescriptorCreateWithAttributes( queryDict ); CFRelease( queryDict ); - CFSetRef mandatoryAttrs = CFSetCreate( - kCFAllocatorDefault, (const void**)&kCTFontURLAttribute, 1, &kCFTypeSetCallBacks ); + // mandatoryAttributes describes which query attributes must match; it is not a list of + // attributes to return. Requiring the URL (which is absent from the query) lets CoreText + // substitute Helvetica for families such as Verdana. + CFSetRef mandatoryAttrs = + CFSetCreate( kCFAllocatorDefault, (const void**)&kCTFontFamilyNameAttribute, 1, + &kCFTypeSetCallBacks ); CFArrayRef matchingDescs = CTFontDescriptorCreateMatchingFontDescriptors( familyDesc, mandatoryAttrs ); @@ -822,6 +860,20 @@ void SystemFontResolver::populateFontList() const { if ( fontPath.empty() ) continue; + CFStringRef postScriptNameRef = + (CFStringRef)CTFontDescriptorCopyAttribute( desc, kCTFontNameAttribute ); + std::string postScriptName = cfStringToStd( postScriptNameRef ); + if ( postScriptNameRef ) + CFRelease( postScriptNameRef ); + + CFStringRef matchedFamilyRef = + (CFStringRef)CTFontDescriptorCopyAttribute( desc, kCTFontFamilyNameAttribute ); + std::string matchedFamily = cfStringToStd( matchedFamilyRef ); + if ( matchedFamilyRef ) + CFRelease( matchedFamilyRef ); + if ( normalizeFamily( matchedFamily ) != normalizeFamily( familyName ) ) + continue; + CFDictionaryRef traits = (CFDictionaryRef)CTFontDescriptorCopyAttribute( desc, kCTFontTraitsAttribute ); CGFloat weightVal = 0.0; @@ -857,9 +909,12 @@ void SystemFontResolver::populateFontList() const { } FontDesc fontDesc; - fontDesc.family = familyName; + fontDesc.family = matchedFamily; fontDesc.path = fontPath; - fontDesc.faceIndex = static_cast( j ); + // j is only the descriptor's position in matchingDescs and is unrelated to the face's + // index inside a TTC. Match CoreText's PostScript name against the actual file faces. + if ( !ftState->findFaceIndex( fontPath, postScriptName, fontDesc.faceIndex ) ) + continue; fontDesc.weight = ctWeightToFontWeight( weightVal ); fontDesc.stretch = ctWidthToFontStretch( widthVal ); fontDesc.italic = ( slantVal > 0.0 ); diff --git a/src/tests/unit_tests/systemfontresolver_tests.cpp b/src/tests/unit_tests/systemfontresolver_tests.cpp index f3473003d..a29c39abe 100644 --- a/src/tests/unit_tests/systemfontresolver_tests.cpp +++ b/src/tests/unit_tests/systemfontresolver_tests.cpp @@ -128,6 +128,24 @@ UTEST( SystemFontResolver, findVerdana ) { query.weight = FontWeight::Normal; query.italic = false; FontDesc desc = resolver->resolve( query ); +#if EE_PLATFORM == EE_PLATFORM_MACOS + auto verdanaFaces = resolver->enumerateFamily( "Verdana" ); + if ( !verdanaFaces.empty() ) { + EXPECT_FALSE( desc.path.empty() ); + EXPECT_STDSTREQ( "Verdana", desc.family ); + EXPECT_TRUE_MSG( + desc.path.find( "Verdana" ) != std::string::npos, + ( "Verdana must not resolve through a substituted CoreText family: " + desc.path ) + .c_str() ); + for ( const FontDesc& face : verdanaFaces ) { + FontTrueTypePtr font = defaultResourceScope().getFontService().loadSystemFont( face ); + EXPECT_TRUE_MSG( + font && font->loaded(), + ( "CoreText face index must load the selected Verdana face: " + face.getFileKey() ) + .c_str() ); + } + } +#endif #if EE_PLATFORM == EE_PLATFORM_LINUX || EE_PLATFORM == EE_PLATFORM_BSD || \ EE_PLATFORM == EE_PLATFORM_WIN || EE_PLATFORM == EE_PLATFORM_MACOS if ( resolver->enumerate().size() > 0 ) {