diff --git a/.agent/plans/system_font_resolver_plan.md b/.agent/plans/system_font_resolver_plan.md new file mode 100644 index 000000000..6f4cb29f0 --- /dev/null +++ b/.agent/plans/system_font_resolver_plan.md @@ -0,0 +1,710 @@ +# SystemFontResolver Architecture Plan + +## Problem Statement + +`eepp` has no mechanism to discover fonts installed on the host operating system. When CSS specifies `font-family: "Helvetica Neue", Arial, sans-serif`, the current resolution chain (`UISceneNode::getFontFromNamesList` → `FontManager::getByName`) can only find fonts that were explicitly loaded via `@font-face` or programmatic `FontTrueType::New`. System font keywords (`caption`, `icon`, `menu`, etc.) are parsed but silently discarded. This makes cross-platform CSS font-family resolution impossible. + +**Goal:** Implement a cross-platform `SystemFontResolver` abstraction that maps CSS `font-family` + weight/style properties to physical font file paths (and face indices for `.ttc` files) by querying the host OS font subsystem. + +--- + +## 1. Target Platform APIs + +| Platform | EE_PLATFORM | OS API | Headers/Libraries | +|----------|-------------|--------|-------------------| +| Windows | `EE_PLATFORM_WIN` | DirectWrite | ``, `dwrite.lib` | +| macOS | `EE_PLATFORM_MACOS` | Core Text | ``, `-framework CoreText` | +| iOS | `EE_PLATFORM_IOS` | Core Text | same as macOS | +| Linux | `EE_PLATFORM_LINUX` | Fontconfig | ``, `-lfontconfig` | +| FreeBSD | `EE_PLATFORM_BSD` | Fontconfig | same as Linux | +| Android (API 29+) | `EE_PLATFORM_ANDROID` | NDK Font API | ``, ``, `-landroid` | +| Android (legacy) | `EE_PLATFORM_ANDROID` | XML fallback | `/system/etc/fonts.xml` | +| Haiku | `EE_PLATFORM_HAIKU` | Interface Kit / BFont | ``, `-lbe` | + +--- + +## 2. Interface Definition + +### 2.1 Header: `include/eepp/graphics/systemfontresolver.hpp` + +```cpp +#ifndef EE_GRAPHICS_SYSTEMFONTRESOLVER_HPP +#define EE_GRAPHICS_SYSTEMFONTRESOLVER_HPP + +#include +#include +#include +#include +#include +#include +#include + +namespace EE { namespace Graphics { + +/** Weight scale matching CSS font-weight (100-900 + keywords mapped to numeric) */ +enum class FontWeight : Uint16 { + Thin = 100, + ExtraLight = 200, + Light = 300, + Normal = 400, + Medium = 500, + SemiBold = 600, + Bold = 700, + ExtraBold = 800, + Black = 900 +}; + +/** Categorization of font stretch/width */ +enum class FontStretch : Uint8 { + UltraCondensed = 1, + ExtraCondensed = 2, + Condensed = 3, + SemiCondensed = 4, + Normal = 5, + SemiExpanded = 6, + Expanded = 7, + ExtraExpanded = 8, + UltraExpanded = 9 +}; + +/** Query sent to the OS font subsystem */ +struct FontQuery { + std::string family; ///< CSS font-family string (e.g. "Arial", "sans-serif") + FontWeight weight { FontWeight::Normal }; + FontStretch stretch { FontStretch::Normal }; + bool italic { false }; +}; + +/** Resolved physical font from the OS */ +struct FontDesc { + std::string path; ///< Full filesystem path to the font file + Uint32 faceIndex {0}; ///< Face index for .ttc/.otc TrueType Collections + FontWeight weight { FontWeight::Normal }; + FontStretch stretch { FontStretch::Normal }; + bool italic { false }; + bool monospace { false }; +}; + +/** Generic font family classification */ +enum class GenericFamily : Uint8 { + Serif, + SansSerif, + Monospace, + Cursive, + Fantasy, + SystemUi, ///< OS default UI font + Emoji, + Unknown +}; + +/** @brief Cross-platform system font discovery and resolution. + * + * Queries the host OS font subsystem to enumerate installed fonts and resolve + * CSS font-family names to physical file paths. All results are cached to + * avoid repeated OS API calls. This is NOT a Font subclass — it is a + * pure resolver that returns file paths consumable by FontTrueType. + */ +class EE_API SystemFontResolver { + SINGLETON_DECLARE_HEADERS( SystemFontResolver ) + + public: + ~SystemFontResolver(); + + /** Enumerate all fonts installed on the system. Expensive — call once, results cached. */ + const std::vector& enumerate(); + + /** Enumerate fonts matching a specific family name. */ + std::vector enumerateFamily( const std::string& family ); + + /** Find the best matching font file for a given query. + * Searches by family, then falls back to generic family defaults. */ + FontDesc resolve( const FontQuery& query ); + + /** Resolve with a font-family CSS list string (e.g. "Helvetica Neue, Arial, sans-serif"). + * Returns the best match across the entire list. */ + FontDesc resolveFromNamesList( const std::string& namesList, FontWeight weight, bool italic ); + + /** Map a generic CSS family keyword to the OS default font path. */ + FontDesc resolveGeneric( GenericFamily generic, FontWeight weight, bool italic ); + + /** Get the default system UI font path. */ + FontDesc getSystemFont() const; + + /** Get the default monospace font path. */ + FontDesc getSystemMonospaceFont() const; + + /** Find a fallback font that contains a specific Unicode codepoint. */ + FontDesc getFallbackForCodepoint( Uint32 codepoint, FontWeight weight, bool italic ); + + /** Check if a font file contains a specific codepoint. */ + bool fontContainsCodepoint( const std::string& path, Uint32 codepoint, + Uint32 faceIndex = 0 ); + + /** Clear the internal caches. Call when fonts are installed/uninstalled at runtime. */ + void invalidateCache(); + + protected: + SystemFontResolver(); + + private: + /** Populate the full font list from the OS. Platform-specific. */ + void populateFontList(); + + /** Map a generic family to platform-default font paths. */ + void populateGenericFallbacks(); + + /** The raw enumerated list from the OS. Populated once, never cleared. */ + std::vector mFontList; + bool mFontListPopulated{ false }; + + /** Cache: (family_lower, weight, stretch, italic) → FontDesc. + * Key uses the lower-cased family name to make lookups case-insensitive. */ + struct CacheKey { + String::HashType familyHash; + Uint16 weight; + Uint8 stretch; + bool italic; + + bool operator==( const CacheKey& o ) const { + return familyHash == o.familyHash && weight == o.weight && + stretch == o.stretch && italic == o.italic; + } + }; + struct CacheKeyHasher { + Uint64 operator()( const CacheKey& k ) const { + return ( static_cast( k.familyHash ) << 32 ) | + ( static_cast( k.weight ) << 16 ) | + ( static_cast( k.stretch ) << 8 ) | + ( static_cast( k.italic ) ); + } + }; + mutable UnorderedMap mResolveCache; + + /** Cache: generic + weight + italic → FontDesc */ + mutable UnorderedMap mGenericCache; + + /** Cache for codepoint fallback lookups. */ + mutable UnorderedMap mCodepointFallbackCache; + + /** Pre-computed generic family mappings (e.g. "sans-serif" → platform default). */ + struct GenericEntry { + GenericFamily generic; + FontDesc desc; + }; + std::vector mGenericFallbacks; + + /** Best-match scoring: given a query and a candidate, compute a fit score (lower is better). */ + static int scoreMatch( const FontQuery& query, const FontDesc& candidate ); +}; + +}} // namespace EE::Graphics + +#endif +``` + +### 2.2 Key Design Decisions + +**Singleton Pattern.** Uses the existing `SINGLETON_DECLARE_HEADERS` / `SINGLETON_DECLARE_IMPLEMENTATION` macros (same as `FontManager`). The singleton is created on first `instance()` call. + +**Not a Font subclass.** The resolver returns `FontDesc` structs (file paths), not `Font*` objects. This avoids coupling font discovery to the FreeType/GPU resource lifecycle. The consumer (`FontManager` or `UISceneNode`) is responsible for creating `FontTrueType` instances from the paths. + +**Case-insensitive matching.** CSS font-family names are case-insensitive. Internal lookups use `String::toLower()` on family names before hashing/querying. + +**faceIndex support.** TrueType Collections (`.ttc`, `.otc`) contain multiple faces in a single file. The `faceIndex` field in `FontDesc` tells `FontTrueType` which face to load from the file. + +--- + +## 3. Factory Routing (Platform Selection) + +### 3.1 Source File Organization + +``` +src/eepp/graphics/ +├── systemfontresolver.hpp # Public header +├── systemfontresolver.cpp # Common implementation (cache logic, matching, generic fallbacks) +├── systemfontresolver_win.cpp # Windows / DirectWrite +├── systemfontresolver_macos.cpp # macOS+iOS / Core Text +├── systemfontresolver_linux.cpp # Linux+BSD / Fontconfig +├── systemfontresolver_android.cpp # Android / NDK Font API + XML fallback +└── systemfontresolver_haiku.cpp # Haiku / BFont +``` + +### 3.2 Routing in `systemfontresolver.hpp` + +```cpp +#if EE_PLATFORM == EE_PLATFORM_WIN + #define EE_SYSTEMFONT_PLATFORM_DEFINED +#elif EE_PLATFORM == EE_PLATFORM_MACOS || EE_PLATFORM == EE_PLATFORM_IOS + #define EE_SYSTEMFONT_PLATFORM_DEFINED +#elif EE_PLATFORM == EE_PLATFORM_LINUX || EE_PLATFORM == EE_PLATFORM_BSD + #define EE_SYSTEMFONT_PLATFORM_DEFINED +#elif EE_PLATFORM == EE_PLATFORM_ANDROID + #define EE_SYSTEMFONT_PLATFORM_DEFINED +#elif EE_PLATFORM == EE_PLATFORM_HAIKU + #define EE_SYSTEMFONT_PLATFORM_DEFINED +#elif EE_PLATFORM == EE_PLATFORM_EMSCRIPTEN + // Emscripten: no system font access; use baked-in fonts only +#endif +``` + +### 3.3 `systemfontresolver.cpp` Common Implementation + +Houses: +- `populateFontList()` stubs (where each platform's implementation gets compiled in) +- `resolve()` — the main matching algorithm: lower-case lookup in `mResolveCache`, iterate `mFontList`, compute `scoreMatch()` scores, return best. +- `resolveGeneric()` — look up `mGenericCache`, fall back to `isFamilyInList()` search, else use hard-coded platform defaults. +- `resolveFromNamesList()` — split by comma, try each family name, `resolve()` each. +- `getFallbackForCodepoint()` — iterate font list, call `fontContainsCodepoint()`, cache results. +- `fontContainsCodepoint()` — use FreeType `FT_New_Face(..., faceIndex, ...)`, `FT_Get_Char_Index()`, then `FT_Done_Face()`. +- `scoreMatch()` — weighted scoring: exact family match (0), family substring match (10), generic match (50), weight distance, italic mismatch penalty, stretch distance. + +### 3.4 Platform Implementations (in separate .cpp files) + +Each platform file implements only `SystemFontResolver::populateFontList()` and `SystemFontResolver::getSystemFont()`. Common code stays in `systemfontresolver.cpp`. + +| Platform | populateFontList Implementation | +|----------|-------------------------------| +| Windows | `IDWriteFactory::GetSystemFontCollection()` → enumerate `IDWriteFontFamily` → `IDWriteFont` → `IDWriteLocalizedStrings` for family name → `IDWriteFontFace::GetFiles()` for paths | +| macOS/iOS | `CTFontManagerCopyAvailableFontFamilyNames()` → for each family: `CTFontDescriptorCreateMatchingFontDescriptors()` → `CTFontDescriptorCopyAttribute(kCTFontURLAttribute)` | +| Linux/BSD | `FcInit()` → `FcPatternCreate()` → `FcFontList()` → `FcPatternGetString(FC_FILE)`, `FcPatternGetInteger(FC_INDEX)`, `FcPatternGetInteger(FC_WEIGHT)`, `FcPatternGetInteger(FC_SLANT)` | +| Android (29+) | `AFontMatcher_create()` → `AFontMatcher_setFamilyVariant()` → use `AFontMatcher` to enumerate | +| Android (legacy) | Parse `/system/etc/fonts.xml` for `` → `` elements | +| Haiku | `BFont::get_family_and_style()` + iterate `BPath` for font directories | + +--- + +## 4. Memory & Performance Strategy + +### 4.1 Caching Architecture (Two Layers) + +``` +┌──────────────────────────────────────────────────────────┐ +│ Layer 1: Enumerated Font List (mFontList) │ +│ - Populated ONCE per process lifetime │ +│ - std::vector, pre-sorted by family │ +│ - Populated lazily on first enumerate()/resolve() call │ +│ - Thread-safe: populated under mutex, read-only after │ +│ - Memory: ~2-5KB per 1000 system fonts (path strings) │ +└──────────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────┐ +│ Layer 2: Resolution Cache (mResolveCache) │ +│ - Key: (familyHash, weight, stretch, italic) │ +│ - Value: FontDesc │ +│ - Built lazily: first resolve(family, weight) calls │ +│ - Hit rate: ~100% after warm-up (font queries are few) │ +│ - Thread-safe: lockless reads after population │ +└──────────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────┐ +│ Layer 2b: Codepoint Fallback Cache │ +│ - Key: Uint32 codepoint │ +│ - Value: font file path string │ +│ - Built lazily per codepoint │ +│ - Hit rate: ~100% for commonly-missing CJK/emoji chars │ +└──────────────────────────────────────────────────────────┘ +``` + +### 4.2 Performance Characteristics + +| Operation | Cold Path | Hot Path | +|-----------|-----------|----------| +| `enumerate()` | 10-80ms (OS query + path normalization) | <1us (cached) | +| `resolve(family, weight)` | First call per family: enumerates all fonts if not yet done | <2us (hash lookup + array scan of family matches) | +| `resolveFromNamesList("Arial, sans-serif", ...)` | Same as resolve for first family | <10us (try each family in list, first hit returns) | +| `getFallbackForCodepoint(0x65E5)` | ~5ms (FreeType FT_New_Face + FT_Get_Char_Index + FT_Done_Face, may scan multiple fonts) | <1us (cached string + path) | +| `fontContainsCodepoint()` | ~1-3ms per font file (FT_New_Face, FT_Get_Char_Index, FT_Done_Face) | Result not cached separately (only via getFallbackForCodepoint) | + +### 4.3 Render-Loop Safety + +**Critical invariant:** `SystemFontResolver` is NEVER called during the render loop. It is called only during: +1. Style resolution (when CSS `font-family` is applied to a widget — this happens during layout/initialization, not drawing) +2. `UISceneNode::getFontFromNamesList()` — called during `applyProperty()` which runs on the UI thread but outside the draw cycle +3. Font fallback codepoint lookups — triggered by `FontTrueType::getGlyph()`, which calls `eeASSERT( Engine::isMainThread() )` and runs on the main thread but MUST NOT call the OS. See Section 6 for the async fallback strategy. + +**For fallback codepoint resolution**, the OS query is too slow for the render path. Instead: +- `getFallbackForCodepoint()` with cache hit → O(1) return, safe for render +- `getFallbackForCodepoint()` with cache miss → queues a deferred load, returns "not found" to render thread, triggers a callback once resolved + +### 4.4 Memory Footprint Strategy + +- **`mFontList`** (vector of FontDesc): Each entry is ~80 bytes (path string SSO + metadata). For 1000 fonts: ~80KB. Acceptable. +- **`mResolveCache`** (hash map): Each entry is ~100 bytes. For 100 resolved queries: ~10KB. +- **`mCodepointFallbackCache`**: Each entry is ~80 bytes. For 1000 codepoints: ~80KB. +- **Total budget:** Under 200KB. No heap pressure beyond this. + +--- + +## 5. FreeType Integration (faceIndex + Loading) + +### 5.1 Changes to `FontTrueType::loadFromFile` + +**Current** (fonttruetype.cpp:350): +```cpp +if ( FT_New_Face( static_cast( mLibrary ), filename.c_str(), 0, &face ) != 0 ) { +``` + +**Modified** — add default parameter `faceIndex = 0`: +```cpp +bool loadFromFile( const std::string& filename, Uint32 faceIndex = 0 ); +``` + +Internal change — use the parameter: +```cpp +if ( FT_New_Face( static_cast( mLibrary ), filename.c_str(), + static_cast( faceIndex ), &face ) != 0 ) { +``` + +### 5.2 Changes to `FontTrueType::loadFromMemory` + +**Current** (fonttruetype.cpp:384-385): +```cpp +if ( FT_New_Memory_Face( static_cast( mLibrary ), + reinterpret_cast( ptr ), + static_cast( sizeInBytes ), 0, &face ) != 0 ) { +``` + +**Modified** — add `faceIndex` parameter: +```cpp +bool loadFromMemory( const void* data, std::size_t sizeInBytes, bool copyData = true, + Uint32 faceIndex = 0 ); +``` + +### 5.3 Changes to `FontTrueType::loadFromStream` + +**Current** (fonttruetype.cpp:427): +```cpp +if ( FT_Open_Face( static_cast( mLibrary ), &args, 0, &face ) != 0 ) { +``` + +**Modified** — add `faceIndex` parameter: +```cpp +bool loadFromStream( IOStream& stream, Uint32 faceIndex = 0 ); +``` + +### 5.4 Changes to `FontTrueType::loadFromPack` + +Add `faceIndex` passthrough: +```cpp +bool loadFromPack( Pack* pack, std::string filePackPath, Uint32 faceIndex = 0 ); +``` + +### 5.5 Changes to `FontTrueType` Static Factory + +Add a new constructor variant: +```cpp +static FontTrueType* New( const std::string& FontName, const std::string& filename, + Uint32 faceIndex ); +``` + +### 5.6 New Member: `mFaceIndex` + +```cpp +// In fonttruetype.hpp, protected section: +Uint32 mFaceIndex{ 0 }; ///< Face index for .ttc TrueType Collections +``` + +Stored on construction and passed through to FreeType calls. Since all current callers use `faceIndex = 0`, this is a purely additive, non-breaking change. + +--- + +## 6. Fallback Glyph Routing Strategy + +### 6.1 Current Fallback Chain in `FontTrueType::getGlyph` + +The existing fallback chain (fonttruetype.cpp:549-619) is: +1. Emoji fallback (color emoji font → emoji font) +2. BoldItalic variant +3. Bold variant +4. Italic variant +5. Own glyph index lookup +6. FontManager fallback fonts (if `mEnableFallbackFont`) + +### 6.2 Proposed: Add OS-Level Fallback as Step 7 + +After step 6 fails (no fallback font from FontManager contains the glyph), add: + +```cpp +// Step 7: Ask the OS for a font containing this codepoint +if ( 0 == idx && mEnableFallbackFont && mEnableSystemFallback ) { + FontDesc fallbackDesc = SystemFontResolver::instance()->getFallbackForCodepoint( + codePoint, FontWeight::Normal, false ); + if ( !fallbackDesc.path.empty() ) { + FontTrueType* systemFallback = getOrLoadSystemFallbackFont( fallbackDesc ); + if ( systemFallback && ( idx = systemFallback->getGlyphIndex( codePoint ) ) ) { + if ( mIsMonospace && mEnableDynamicMonospace ) { + mIsMonospaceComplete = false; + mUsingFallback = true; + } + return systemFallback->getGlyphByIndex( idx, characterSize, bold, italic, + outlineThickness, getPage( characterSize ) ); + } + } +} +``` + +### 6.3 New Member: `mEnableSystemFallback` + +```cpp +// In fonttruetype.hpp: +bool mEnableSystemFallback{ true }; + +public: +bool isSystemFallbackEnabled() const; +void setEnableSystemFallback( bool enableSystemFallback ); +``` + +### 6.4 System Fallback Font Cache in FontManager + +To avoid repeatedly loading the same system font file for every codepoint miss, `FontManager` maintains a small LRU cache of loaded system fallback fonts: + +```cpp +// In fontmanager.hpp, protected section: +std::vector> mSystemFallbackFonts; +static constexpr Uint32 MAX_SYSTEM_FALLBACK_FONTS = 8; + +public: +FontTrueType* getOrLoadSystemFallbackFont( const FontDesc& desc ); +``` + +**Flow:** +1. `FontManager::getOrLoadSystemFallbackFont()` checks if a `FontTrueType*` for the path+faceIndex already exists in `mSystemFallbackFonts`. +2. If yes, returns cached pointer. +3. If no, calls `FontTrueType::New(family, desc.path, desc.faceIndex)`, adds to cache. If cache exceeds `MAX_SYSTEM_FALLBACK_FONTS`, evicts LRU. + +### 6.5 Asynchronous Codepoint Fallback Prefetch + +For codepoints that the `getFallbackForCodepoint()` cache misses on, we have two options: + +**Option A (Recommended): Synchronous with fast path.** On first miss for a codepoint, `getFallbackForCodepoint()` scans the already-enumerated `mFontList` in-memory (no OS calls), checks `fontContainsCodepoint()` with FreeType's `FT_New_Face` + `FT_Get_Char_Index` + `FT_Done_Face`. Since `FT_New_Face` for a single font file is ~0.5-2ms and we may need to scan ~20 fonts to find a match, total cost for first miss is ~10-40ms. This is acceptable because: +- It only happens once per unique missing codepoint (result is cached) +- It happens outside the render loop (during layout/initialization) +- For CJK text, common hanzi/kanji will be in the first few system CJK fonts + +**Option B (Future optimization): Deferred.** On cache miss, enqueue a background task, return empty. Font will appear on next frame after load completes. + +**We implement Option A for initial release.** + +### 6.6 `fontContainsCodepoint()` Implementation Strategy + +```cpp +bool SystemFontResolver::fontContainsCodepoint( const std::string& path, Uint32 codepoint, + Uint32 faceIndex ) { + // Use FreeType to quickly check without creating a full FontTrueType + FT_Library ftLib; + if ( FT_Init_FreeType( &ftLib ) != 0 ) + return false; + + FT_Face face; + if ( FT_New_Face( ftLib, path.c_str(), static_cast( faceIndex ), &face ) != 0 ) { + FT_Done_FreeType( ftLib ); + return false; + } + + bool hasGlyph = FT_Get_Char_Index( face, codepoint ) != 0; + + FT_Done_Face( face ); + FT_Done_FreeType( ftLib ); + return hasGlyph; +} +``` + +**Optimization:** Use a per-thread FreeType library to avoid `FT_Init_FreeType`/`FT_Done_FreeType` overhead. Store in thread-local storage. + +### 6.7 Integration Point: `UISceneNode::getFontFromNamesList` + +Modify the cascade so that after `FontManager::getByName()` fails for all names in the list, `SystemFontResolver::resolveFromNamesList()` is called: + +```cpp +Font* UISceneNode::getFontFromNamesList( std::string_view names ) const { + Font* font = nullptr; + String::readBySeparatorStoppable( + names, + [&font]( std::string_view name ) { + name = String::trim( name, ' ' ); + name = String::trim( name, '\'' ); + font = FontManager::instance()->getByName( std::string{ name } ); + return font == nullptr; + }, + ',' ); + + // NEW: System font fallback + if ( !font && SystemFontResolver::existsSingleton() ) { + FontDesc desc = SystemFontResolver::instance()->resolveFromNamesList( + std::string{ names }, FontWeight::Normal, false ); + if ( !desc.path.empty() ) { + // Load via FontFamily or FontTrueType::New + FontTrueType* ttf = FontTrueType::New( desc.path, desc.path, desc.faceIndex ); + if ( ttf && ttf->loaded() ) + font = ttf; + } + } + + return font; +} +``` + +--- + +## 7. Changes to CSS Shorthand Parser + +### 7.1 `stylesheetspecification.cpp` — System Font Keywords + +**Current state** (line 1221-1226): Six CSS system font keywords (`caption`, `icon`, `menu`, `message-box`, `small-caption`, `status-bar`) are detected but return empty. + +**Fix:** When a system font keyword is detected, resolve it via `SystemFontResolver` and return the appropriate sub-properties: + +```cpp +static const UnorderedMap systemFontToGeneric = { + { "caption", GenericFamily::SystemUi }, + { "icon", GenericFamily::SystemUi }, + { "menu", GenericFamily::SystemUi }, + { "message-box", GenericFamily::SystemUi }, + { "small-caption",GenericFamily::SystemUi }, + { "status-bar", GenericFamily::SystemUi }, +}; + +for ( const auto& sysFont : systemFontToGeneric ) { + if ( lowerVal == sysFont.first ) { + if ( SystemFontResolver::existsSingleton() ) { + FontDesc desc = SystemFontResolver::instance()->resolveGeneric( + sysFont.second, FontWeight::Normal, false ); + // Build StyleSheetProperty vector with resolved family and metadata + return { + StyleSheetProperty( "font-family", desc.path ), + // ... font-style, font-size from system font metadata + }; + } + return {}; // No resolver → no system fonts available + } +} +``` + +--- + +## 8. Generic Font Family Resolution + +CSS defines five generic font families: `serif`, `sans-serif`, `monospace`, `cursive`, `fantasy`. Plus `system-ui`, `emoji`, `math`, `fangsong` (CSS Fonts Level 4). + +### 8.1 Default Mappings Per Platform + +| Generic | Windows | macOS | Linux (Fontconfig) | Android | Haiku | +|---------|---------|-------|---------------------|---------|-------| +| `serif` | "Times New Roman" | "Times" | `FC_SERIF` → "DejaVu Serif" or "Liberation Serif" | "Noto Serif" | "Noto Serif" | +| `sans-serif` | "Arial" | "Helvetica" | `FC_SANS` → "DejaVu Sans" or "Liberation Sans" | "Roboto" | "Noto Sans" | +| `monospace` | "Consolas" | "Menlo" | `FC_MONO` → "DejaVu Sans Mono" or "Liberation Mono" | "Droid Sans Mono" | "Noto Sans Mono" | +| `cursive` | "Comic Sans MS" | "Apple Chancery" | `FC_SANS` (fallback) | "Dancing Script" | "Noto Sans" | +| `fantasy` | "Impact" | "Papyrus" | `FC_SANS` (fallback) | "Noto Sans" | "Noto Sans" | +| `system-ui` | "Segoe UI" | "SF Pro" | Fontconfig default | "Roboto" | "Noto Sans" | +| `emoji` | "Segoe UI Emoji" | "Apple Color Emoji" | "Noto Color Emoji" | "Noto Color Emoji" | "Noto Color Emoji" | + +These are resolved in `resolveGeneric()` via `mGenericFallbacks`, populated per-platform in `populateGenericFallbacks()`. + +### 8.2 Integration into `resolve()` + +When `resolve()` is called with a family name, the matching algorithm: +1. Check `mResolveCache` (hash hit) +2. Search `mFontList` for exact family match +3. If family is a known CSS generic keyword → call `resolveGeneric()` +4. If no match found → try nearest family in `mFontList` (Levenshtein/prefix match) +5. Return empty `FontDesc` if nothing found + +--- + +## 9. Thread Safety + +| Structure | Access Pattern | Thread Safety | +|-----------|---------------|---------------| +| `mFontList` | Write-once on populate, read-only after | Mutex-guarded populate, lockless reads after flag set | +| `mResolveCache` | Lazy insert, concurrent reads | Fine-grained mutex per insert; reads are lockless (insertions are atomic from reader POV since we store plain structs) | +| `mCodepointFallbackCache` | Lazy insert, concurrent reads | Same as above | +| `mGenericCache` | Populated at startup, read-only after | No synchronization needed after init | +| `fontContainsCodepoint()` | Creates/destroys FT_Library per call | Must not be called concurrently for same library. Per-call FT_Library init/done is safe. | + +**FreeType Library Sharing:** For `fontContainsCodepoint()`, each call creates a fresh `FT_Library`, opens the face, checks, and cleans up. This is threadsafe (FreeType 2.10+ is threadsafe when using separate libraries per thread). For optimization, a per-thread `FT_Library` thread-local can be used. + +--- + +## 10. Implementation Order + +### Phase 1: Core Interface + Stub +1. Create `include/eepp/graphics/systemfontresolver.hpp` — full public interface +2. Create `src/eepp/graphics/systemfontresolver.cpp` — common implementation (enumeration caching, matching/scoring, generic fallback lookup, `resolveFromNamesList`, `fontContainsCodepoint`) +3. Create no-op stubs for all five platform files (return empty lists) +4. Integrate into `premake4.lua` / build system +5. Compile and verify + +### Phase 2: Platform Implementations +6. Linux/BSD Fontconfig (`systemfontresolver_linux.cpp`) → test +7. Windows DirectWrite (`systemfontresolver_win.cpp`) → test on Windows +8. macOS/iOS Core Text (`systemfontresolver_macos.cpp`) → test on macOS +9. Android NDK API 29+ + XML fallback (`systemfontresolver_android.cpp`) +10. Haiku BFont (`systemfontresolver_haiku.cpp`) + +### Phase 3: FreeType faceIndex Integration +11. Add `faceIndex` parameter to all `loadFrom*` methods (default 0, backward compatible) +12. Add `mFaceIndex` member to `FontTrueType` +13. Add `FontTrueType::New(family, path, faceIndex)` factory + +### Phase 4: Fallback Glyph Routing +14. Add `mEnableSystemFallback` flag to `FontTrueType` +15. Implement `FontManager::getOrLoadSystemFallbackFont()` +16. Add OS fallback step to `FontTrueType::getGlyph()` and `getGlyphDrawable()` +17. Add codepoint fallback cache + `fontContainsCodepoint()` + +### Phase 5: CSS Integration +18. Fix system font keywords in `stylesheetspecification.cpp` font shorthand parser +19. Add generic font family resolution to `getFontFromNamesList` in `uiscenenode.cpp` +20. Wire `SystemFontResolver::resolveFromNamesList()` into `getFontFromNamesList` + +### Phase 6: Testing & Validation +21. Write unit tests for `SystemFontResolver` (mockable with known font fixtures) +22. Run existing font rendering tests to verify no regressions +23. Test on all platforms + +--- + +## 11. Risk Assessment + +| Risk | Severity | Mitigation | +|------|----------|------------| +| FreeType init/done overhead in `fontContainsCodepoint()` | MEDIUM | Per-thread `FT_Library` cache in thread-local; amortized across codepoint batches | +| Fontconfig being slow on first query | MEDIUM | Lazy populate; `FcFontList()` is the unavoidable first-hit cost (~60ms) | +| Android XML parsing for legacy fallback | LOW | Only on API < 29; happens once, cached | +| TTC face index mismatches | LOW | Each platform API provides the face index natively (Fontconfig: `FC_INDEX`, DirectWrite: `IDWriteFontFace::GetIndex`, Core Text: implicit) | +| Breaking existing loadFromFile callers | NONE | All `faceIndex` parameters default to `0` | +| Memory from cached system fallback fonts | LOW | `MAX_SYSTEM_FALLBACK_FONTS = 8`, capped | +| Emscripten platform | LOW | No system fonts available; resolver returns empty; CSS relies on `@font-face` or bundled fonts only | + +--- + +## 12. Verification + +### Unit Tests +- `SystemFontResolver.enumerate` — verify `mFontList` is non-empty on all desktop platforms +- `SystemFontResolver.resolve_ExactFamily` — query "Arial" → verify path exists +- `SystemFontResolver.resolve_GenericFamily` — query "sans-serif" → verify valid path +- `SystemFontResolver.resolveFromNamesList` — "NonExistent, sans-serif" → returns sans-serif path +- `SystemFontResolver.fontContainsCodepoint` — ASCII codepoints in known fonts +- `SystemFontResolver.getFallbackForCodepoint` — CJK codepoint (0x65E5) → valid CJK font path + +### Integration Tests +- `FontTrueType.loadFromTTC` — load from a .ttc file with faceIndex > 0 +- `UISceneNode.getFontFromNamesList_SystemFallback` — CSS font-family with only system font names +- `FontManager.systemFallbackFonts` — verify LRU eviction behavior + +### Existing Test Regression +- All font rendering tests must pass unchanged (`FontRendering.*`, `Text.*`) +- All UI layout tests must pass (system fonts only activate on codepoint miss, which existing tests should not trigger) + +--- + +## 13. Non-Scope (Future Extensions) + +- **Font variations (variable fonts with weight/width/slant axes):** The `FontQuery`/`FontDesc` structs can be extended later with axis coordinates. +- **Background font enumeration (async):** For very large font collections (1000+ fonts), `populateFontList()` could be moved to a background thread. +- **Font installation monitoring:** Detecting OS font install/uninstall at runtime and invalidating caches. +- **Emoji font discovery:** Currently hard-coded; could leverage `SystemFontResolver` to auto-detect emoji fonts. +- **PDF/print font embedding:** Using resolved file paths to embed fonts in generated PDFs. diff --git a/.ecode/project_build.json b/.ecode/project_build.json index a6aadfa91..9e6098a1d 100644 --- a/.ecode/project_build.json +++ b/.ecode/project_build.json @@ -172,7 +172,7 @@ "eepp-linux": { "build": [ { - "args": "--disable-static-build --with-mold-linker --with-debug-symbols --address-sanitizer --with-backend=SDL3 gmake", + "args": "--disable-static-build --with-mold-linker --with-debug-symbols --address-sanitizer gmake", "command": "premake4", "working_dir": "${project_root}" }, diff --git a/include/eepp/graphics.hpp b/include/eepp/graphics.hpp index ba8bdf8d0..4cd5df98a 100644 --- a/include/eepp/graphics.hpp +++ b/include/eepp/graphics.hpp @@ -56,6 +56,7 @@ #include #include #include +#include #include #include #include diff --git a/include/eepp/graphics/fontmanager.hpp b/include/eepp/graphics/fontmanager.hpp index a7cd78250..59624f305 100644 --- a/include/eepp/graphics/fontmanager.hpp +++ b/include/eepp/graphics/fontmanager.hpp @@ -10,6 +10,9 @@ using namespace EE::System; namespace EE { namespace Graphics { +class FontTrueType; +struct FontDesc; + /** @brief The Font Manager is a singleton class that manages all the instance of fonts instantiated. And releases the font instances automatically. So the user doesn't need to release any font instance. @@ -49,10 +52,13 @@ class EE_API FontManager : public ResourceManager { Font* getByInternalId( Uint32 internalId ) const; + FontTrueType* getOrLoadSystemFallbackFont( const FontDesc& desc ); + protected: Font* mColorEmojiFont{ nullptr }; Font* mEmojiFont{ nullptr }; std::vector mFallbackFonts; + std::vector mSystemFallbackFonts; FontHinting mHinting{ FontHinting::Full }; FontAntialiasing mAntialiasing{ FontAntialiasing::Grayscale }; diff --git a/include/eepp/graphics/fonttruetype.hpp b/include/eepp/graphics/fonttruetype.hpp index 78241e9c0..b122fde47 100644 --- a/include/eepp/graphics/fonttruetype.hpp +++ b/include/eepp/graphics/fonttruetype.hpp @@ -19,15 +19,19 @@ class EE_API FontTrueType : public Font { static FontTrueType* New( const std::string& FontName, const std::string& filename ); + static FontTrueType* New( const std::string& FontName, const std::string& filename, + Uint32 faceIndex ); + ~FontTrueType(); - bool loadFromFile( const std::string& filename ); + bool loadFromFile( const std::string& filename, Uint32 faceIndex = 0 ); - bool loadFromMemory( const void* data, std::size_t sizeInBytes, bool copyData = true ); + bool loadFromMemory( const void* data, std::size_t sizeInBytes, bool copyData = true, + Uint32 faceIndex = 0 ); - bool loadFromStream( IOStream& stream ); + bool loadFromStream( IOStream& stream, Uint32 faceIndex = 0 ); - bool loadFromPack( Pack* pack, std::string filePackPath ); + bool loadFromPack( Pack* pack, std::string filePackPath, Uint32 faceIndex = 0 ); const Font::Info& getInfo() const; @@ -111,6 +115,10 @@ class EE_API FontTrueType : public Font { void setEnableFallbackFont( bool enableFallbackFont ); + bool isSystemFallbackEnabled() const; + + void setEnableSystemFallback( bool enableSystemFallback ); + bool getEnableDynamicMonospace() const; void setEnableDynamicMonospace( bool enableDynamicMonospace ); @@ -239,6 +247,7 @@ class EE_API FontTrueType : public Font { mutable bool mUsingFallback{ false }; bool mEnableEmojiFallback{ true }; bool mEnableFallbackFont{ true }; + bool mEnableSystemFallback{ true }; bool mEnableDynamicMonospace{ false }; bool mIsBold{ false }; bool mIsItalic{ false }; @@ -246,10 +255,11 @@ class EE_API FontTrueType : public Font { mutable UnorderedMap mClosestCharacterSize; mutable UnorderedMap mCodePointIndexCache; mutable UnorderedMap> mKeyCache; - mutable UnorderedMap mKerningCache; // For codepoints (getKerning) - mutable UnorderedMap mKerningGlyphCache; // For glyph indices + mutable UnorderedMap mKerningCache; // For codepoints (getKerning) + mutable UnorderedMap mKerningGlyphCache; // For glyph indices FontHinting mHinting{ FontHinting::Full }; FontAntialiasing mAntialiasing{ FontAntialiasing::Grayscale }; + Uint32 mFaceIndex{ 0 }; FontTrueType* mFontBold{ nullptr }; FontTrueType* mFontItalic{ nullptr }; FontTrueType* mFontBoldItalic{ nullptr }; diff --git a/include/eepp/graphics/systemfontresolver.hpp b/include/eepp/graphics/systemfontresolver.hpp new file mode 100644 index 000000000..81920fa51 --- /dev/null +++ b/include/eepp/graphics/systemfontresolver.hpp @@ -0,0 +1,132 @@ +#ifndef EE_GRAPHICS_SYSTEMFONTRESOLVER_HPP +#define EE_GRAPHICS_SYSTEMFONTRESOLVER_HPP + +#include +#include +#include + +#include +#include + +namespace EE { namespace Graphics { + +enum class FontWeight : Uint16 { + Thin = 100, + ExtraLight = 200, + Light = 300, + Normal = 400, + Medium = 500, + SemiBold = 600, + Bold = 700, + ExtraBold = 800, + Black = 900 +}; + +enum class FontStretch : Uint8 { + UltraCondensed = 1, + ExtraCondensed = 2, + Condensed = 3, + SemiCondensed = 4, + Normal = 5, + SemiExpanded = 6, + Expanded = 7, + ExtraExpanded = 8, + UltraExpanded = 9 +}; + +enum class GenericFamily : Uint8 { + None, + Serif, + SansSerif, + Monospace, + Cursive, + Fantasy, + SystemUi, + Emoji +}; + +struct FontQuery { + std::string family; + FontWeight weight{ FontWeight::Normal }; + FontStretch stretch{ FontStretch::Normal }; + bool italic{ false }; +}; + +struct FontDesc { + std::string family; + std::string path; + Uint32 faceIndex{ 0 }; + FontWeight weight{ FontWeight::Normal }; + FontStretch stretch{ FontStretch::Normal }; + bool italic{ false }; + bool monospace{ false }; +}; + +class EE_API SystemFontResolver { + SINGLETON_DECLARE_HEADERS( SystemFontResolver ) + + public: + ~SystemFontResolver(); + + const std::vector& enumerate(); + + std::vector enumerateFamily( const std::string& family ); + + FontDesc resolve( const FontQuery& query ); + + FontDesc resolveFromNamesList( const std::string& namesList, FontWeight weight, bool italic ); + + FontDesc resolveGeneric( GenericFamily generic, FontWeight weight, bool italic ); + + FontDesc getSystemFont() const; + + FontDesc getSystemMonospaceFont() const; + + FontDesc getFallbackForCodepoint( Uint32 codepoint, FontWeight weight, bool italic ); + + bool fontContainsCodepoint( const std::string& path, Uint32 codepoint ); + + void invalidateCache(); + + void ensureFontListPopulated() const; + + static void setEnabled( bool enabled ); + + static bool isEnabled(); + + static GenericFamily genericFamilyFromName( const std::string& name ); + + protected: + SystemFontResolver(); + + private: + void populateFontList() const; + + void populateGenericFallbacks() const; + + static int scoreMatch( const FontQuery& query, const FontDesc& candidate ); + + mutable std::vector mFontList; + mutable bool mFontListPopulated{ false }; + + static Uint64 makeCacheKey( const std::string& normFamily, FontWeight weight, + FontStretch stretch, bool italic ); + + mutable UnorderedMap mResolveCache; + + mutable UnorderedMap mGenericCache; + + mutable UnorderedMap mCodepointFallbackCache; + + struct GenericEntry { + GenericFamily generic; + FontDesc desc; + }; + mutable std::vector mGenericFallbacks; + + static bool sEnabled; +}; + +}} // namespace EE::Graphics + +#endif diff --git a/include/eepp/ui/uiscenenode.hpp b/include/eepp/ui/uiscenenode.hpp index e6c65bc68..84f3871e6 100644 --- a/include/eepp/ui/uiscenenode.hpp +++ b/include/eepp/ui/uiscenenode.hpp @@ -736,6 +736,10 @@ class EE_API UISceneNode : public SceneNode { Font* getFontFromNamesList( std::string_view names, Uint32 fontStyle = 0 ) const; + Font* reevaluateFontStyle( Font* currentFont, Uint32 fontStyle ) const; + + void loadFontStyleVariants( Font* font, const std::string& family ) const; + protected: friend class EE::UI::UIWindow; friend class EE::UI::UIWidget; diff --git a/premake4.lua b/premake4.lua index 924472c96..c268f1abf 100644 --- a/premake4.lua +++ b/premake4.lua @@ -671,19 +671,19 @@ function generate_os_links() table.insert( os_links, "dl" ) end elseif os.is_real("windows") then - multiple_insert( os_links, { "opengl32", "glu32", "gdi32", "ws2_32", "winmm", "ole32", "uuid" } ) + multiple_insert( os_links, { "opengl32", "glu32", "gdi32", "ws2_32", "winmm", "ole32", "uuid", "dwrite" } ) elseif os.is_real("mingw32") then - multiple_insert( os_links, { "opengl32", "glu32", "gdi32", "ws2_32", "winmm", "ole32", "uuid" } ) + multiple_insert( os_links, { "opengl32", "glu32", "gdi32", "ws2_32", "winmm", "ole32", "uuid", "dwrite" } ) elseif os.is_real("mingw64") then - multiple_insert( os_links, { "opengl32", "glu32", "gdi32", "ws2_32", "winmm", "ole32", "uuid" } ) + multiple_insert( os_links, { "opengl32", "glu32", "gdi32", "ws2_32", "winmm", "ole32", "uuid", "dwrite" } ) elseif os.is_real("macosx") then - multiple_insert( os_links, { "OpenGL.framework", "CoreFoundation.framework" } ) + multiple_insert( os_links, { "OpenGL.framework", "CoreFoundation.framework", "CoreText.framework" } ) elseif os.is_real("freebsd") then multiple_insert( os_links, { "rt", "pthread", "GL" } ) elseif os.is_real("haiku") then multiple_insert( os_links, { "GL", "network" } ) elseif os.is_real("ios") then - multiple_insert( os_links, { "OpenGLES.framework", "AudioToolbox.framework", "CoreAudio.framework", "Foundation.framework", "CoreFoundation.framework", "UIKit.framework", "QuartzCore.framework", "CoreGraphics.framework", "CoreMotion.framework", "AVFoundation.framework", "GameController.framework" } ) + multiple_insert( os_links, { "OpenGLES.framework", "AudioToolbox.framework", "CoreAudio.framework", "Foundation.framework", "CoreFoundation.framework", "CoreText.framework", "UIKit.framework", "QuartzCore.framework", "CoreGraphics.framework", "CoreMotion.framework", "AVFoundation.framework", "GameController.framework" } ) end if _OPTIONS["without-mojoal"] then @@ -1051,6 +1051,10 @@ function build_eepp( build_name ) links { "bcrypt" } end + if os.is_real("linux") or os.is_real("bsd") then + links { "fontconfig" } + end + files { "src/eepp/core/*.cpp", "src/eepp/math/*.cpp", "src/eepp/system/*.cpp", diff --git a/premake5.lua b/premake5.lua index cda7efffa..ae2875290 100644 --- a/premake5.lua +++ b/premake5.lua @@ -512,17 +512,17 @@ function generate_os_links() table.insert( os_links, "dl" ) end elseif os.istarget("windows") then - multiple_insert( os_links, { "opengl32", "glu32", "gdi32", "ws2_32", "winmm", "ole32", "uuid" } ) + multiple_insert( os_links, { "opengl32", "glu32", "gdi32", "ws2_32", "winmm", "ole32", "uuid", "dwrite" } ) elseif os.istarget("mingw32") then - multiple_insert( os_links, { "opengl32", "glu32", "gdi32", "ws2_32", "winmm", "ole32", "uuid" } ) + multiple_insert( os_links, { "opengl32", "glu32", "gdi32", "ws2_32", "winmm", "ole32", "uuid", "dwrite" } ) elseif os.istarget("macosx") then - multiple_insert( os_links, { "OpenGL.framework", "CoreFoundation.framework" } ) + multiple_insert( os_links, { "OpenGL.framework", "CoreFoundation.framework", "CoreText.framework" } ) elseif os.istarget("bsd") then multiple_insert( os_links, { "rt", "pthread", "GL" } ) elseif os.istarget("haiku") then multiple_insert( os_links, { "GL", "network" } ) elseif os.istarget("ios") then - multiple_insert( os_links, { "OpenGLES.framework", "AudioToolbox.framework", "CoreAudio.framework", "Foundation.framework", "CoreFoundation.framework", "UIKit.framework", "QuartzCore.framework", "CoreGraphics.framework", "CoreMotion.framework", "AVFoundation.framework", "GameController.framework" } ) + multiple_insert( os_links, { "OpenGLES.framework", "AudioToolbox.framework", "CoreAudio.framework", "Foundation.framework", "CoreFoundation.framework", "CoreText.framework", "UIKit.framework", "QuartzCore.framework", "CoreGraphics.framework", "CoreMotion.framework", "AVFoundation.framework", "GameController.framework" } ) elseif os.istarget("android") then multiple_insert( os_links, { "GLESv1_CM", "GLESv2", "log" } ) end @@ -921,6 +921,9 @@ function build_eepp( build_name ) filter { "action:export-compile-commands", "system:macosx" } buildoptions { "-std=c++20" } + filter "system:linux or system:bsd" + links { "fontconfig" } + filter {} end diff --git a/src/eepp/graphics/fontmanager.cpp b/src/eepp/graphics/fontmanager.cpp index dd5e9dda6..b740e197a 100644 --- a/src/eepp/graphics/fontmanager.cpp +++ b/src/eepp/graphics/fontmanager.cpp @@ -1,5 +1,7 @@ #include #include +#include +#include namespace EE { namespace Graphics { @@ -11,6 +13,7 @@ FontManager::~FontManager() { mEmojiFont = nullptr; mColorEmojiFont = nullptr; mFallbackFonts.clear(); + mSystemFallbackFonts.clear(); } Graphics::Font* FontManager::add( Graphics::Font* font ) { @@ -101,4 +104,35 @@ Font* FontManager::getByInternalId( Uint32 internalId ) const { return nullptr; } +FontTrueType* FontManager::getOrLoadSystemFallbackFont( const FontDesc& desc ) { + static constexpr Uint32 MAX_SYSTEM_FALLBACK_FONTS = 32; + + if ( desc.path.empty() ) + return nullptr; + + for ( auto* font : mSystemFallbackFonts ) { + if ( font->getType() == FontType::TTF ) { + auto* ttf = static_cast( font ); + if ( ttf->getInfo().fontpath + ttf->getInfo().filename == desc.path ) + return ttf; + } + } + + FontTrueType* ttf = FontTrueType::New( desc.family, desc.path, desc.faceIndex ); + if ( !ttf || !ttf->loaded() ) { + eeSAFE_DELETE( ttf ); + return nullptr; + } + + mSystemFallbackFonts.push_back( ttf ); + + if ( mSystemFallbackFonts.size() > MAX_SYSTEM_FALLBACK_FONTS ) { + Font* oldest = mSystemFallbackFonts.front(); + mSystemFallbackFonts.erase( mSystemFallbackFonts.begin() ); + eeSAFE_DELETE( oldest ); + } + + return ttf; +} + }} // namespace EE::Graphics diff --git a/src/eepp/graphics/fonttruetype.cpp b/src/eepp/graphics/fonttruetype.cpp index 61eea539f..20a1395fb 100644 --- a/src/eepp/graphics/fonttruetype.cpp +++ b/src/eepp/graphics/fonttruetype.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include #include #include @@ -261,6 +262,13 @@ FontTrueType* FontTrueType::New( const std::string& FontName, const std::string& return fontTrueType; } +FontTrueType* FontTrueType::New( const std::string& FontName, const std::string& filename, + Uint32 faceIndex ) { + FontTrueType* fontTrueType = New( FontName ); + fontTrueType->loadFromFile( filename, faceIndex ); + return fontTrueType; +} + FontTrueType::FontTrueType( const std::string& FontName ) : Font( FontType::TTF, FontName ), mLibrary( NULL ), @@ -280,12 +288,14 @@ FontTrueType::FontTrueType( const std::string& FontName ) : mUsingFallback( false ), mEnableEmojiFallback( true ), mEnableFallbackFont( true ), + mEnableSystemFallback( true ), mEnableDynamicMonospace( false ), mIsBold( false ), mIsItalic( false ), mIsMonospaceCompletePending( false ), mHinting( FontManager::instance()->getHinting() ), mAntialiasing( FontManager::instance()->getAntialiasing() ), + mFaceIndex( 0 ), mFontBold( nullptr ), mFontItalic( nullptr ), mFontBoldItalic( nullptr ), @@ -318,7 +328,7 @@ static bool checkHasColrTable( const FT_Face& face ) { return length > 0; } -bool FontTrueType::loadFromFile( const std::string& filename ) { +bool FontTrueType::loadFromFile( const std::string& filename, Uint32 faceIndex ) { if ( !FileSystem::fileExists( filename ) && PackManager::instance()->isFallbackToPacksActive() ) { std::string path( filename ); @@ -327,7 +337,7 @@ bool FontTrueType::loadFromFile( const std::string& filename ) { if ( NULL != pack ) { Log::info( "Loading font from pack: %s", path.c_str() ); - return loadFromPack( pack, path ); + return loadFromPack( pack, path, faceIndex ); } return false; @@ -336,6 +346,8 @@ bool FontTrueType::loadFromFile( const std::string& filename ) { // Cleanup the previous resources cleanup(); + mFaceIndex = faceIndex; + // Initialize FreeType FT_Library library; if ( FT_Init_FreeType( &library ) != 0 ) { @@ -347,7 +359,8 @@ bool FontTrueType::loadFromFile( const std::string& filename ) { // Load the new font face from the specified file FT_Face face; - if ( FT_New_Face( static_cast( mLibrary ), filename.c_str(), 0, &face ) != 0 ) { + if ( FT_New_Face( static_cast( mLibrary ), filename.c_str(), + static_cast( mFaceIndex ), &face ) != 0 ) { Log::error( "Failed to load font \"%s\" (%s) (failed to create the font face)", filename.c_str(), mFontName.c_str() ); return false; @@ -359,7 +372,8 @@ bool FontTrueType::loadFromFile( const std::string& filename ) { return setFontFace( face ); } -bool FontTrueType::loadFromMemory( const void* data, std::size_t sizeInBytes, bool copyData ) { +bool FontTrueType::loadFromMemory( const void* data, std::size_t sizeInBytes, bool copyData, + Uint32 faceIndex ) { const void* ptr = data; if ( copyData ) { @@ -371,6 +385,8 @@ bool FontTrueType::loadFromMemory( const void* data, std::size_t sizeInBytes, bo // Cleanup the previous resources cleanup(); + mFaceIndex = faceIndex; + // Initialize FreeType FT_Library library; if ( FT_Init_FreeType( &library ) != 0 ) { @@ -383,7 +399,8 @@ bool FontTrueType::loadFromMemory( const void* data, std::size_t sizeInBytes, bo FT_Face face; if ( FT_New_Memory_Face( static_cast( mLibrary ), reinterpret_cast( ptr ), - static_cast( sizeInBytes ), 0, &face ) != 0 ) { + static_cast( sizeInBytes ), + static_cast( mFaceIndex ), &face ) != 0 ) { Log::error( "Failed to load font from memory (failed to create the font face)" ); return false; } @@ -391,10 +408,12 @@ bool FontTrueType::loadFromMemory( const void* data, std::size_t sizeInBytes, bo return setFontFace( face ); } -bool FontTrueType::loadFromStream( IOStream& stream ) { +bool FontTrueType::loadFromStream( IOStream& stream, Uint32 faceIndex ) { // Cleanup the previous resources cleanup(); + mFaceIndex = faceIndex; + // Initialize FreeType FT_Library library; if ( FT_Init_FreeType( &library ) != 0 ) { @@ -424,7 +443,8 @@ bool FontTrueType::loadFromStream( IOStream& stream ) { // Load the new font face from the specified stream FT_Face face; - if ( FT_Open_Face( static_cast( mLibrary ), &args, 0, &face ) != 0 ) { + if ( FT_Open_Face( static_cast( mLibrary ), &args, + static_cast( mFaceIndex ), &face ) != 0 ) { Log::error( "Failed to load font from stream (failed to create the font face)" ); delete rec; return false; @@ -437,7 +457,7 @@ bool FontTrueType::loadFromStream( IOStream& stream ) { return res; } -bool FontTrueType::loadFromPack( Pack* pack, std::string filePackPath ) { +bool FontTrueType::loadFromPack( Pack* pack, std::string filePackPath, Uint32 faceIndex ) { if ( NULL == pack ) return false; @@ -446,7 +466,7 @@ bool FontTrueType::loadFromPack( Pack* pack, std::string filePackPath ) { mMemCopy.clear(); if ( pack->isOpen() && pack->extractFileToMemory( filePackPath, mMemCopy ) ) - ret = loadFromMemory( mMemCopy.get(), mMemCopy.length(), false ); + ret = loadFromMemory( mMemCopy.get(), mMemCopy.length(), false, faceIndex ); mInfo.fontpath = FileSystem::fileRemoveFileName( filePackPath ); mInfo.filename = FileSystem::fileNameFromPath( filePackPath ); @@ -615,6 +635,23 @@ Glyph FontTrueType::getGlyph( Uint32 codePoint, unsigned int characterSize, bool } } + if ( 0 == idx && mEnableSystemFallback && SystemFontResolver::existsSingleton() ) { + FontDesc fallbackDesc = SystemFontResolver::instance()->getFallbackForCodepoint( + codePoint, FontWeight::Normal, false ); + if ( !fallbackDesc.path.empty() ) { + FontTrueType* systemFallback = + FontManager::instance()->getOrLoadSystemFallbackFont( fallbackDesc ); + if ( systemFallback && ( idx = systemFallback->getGlyphIndex( codePoint ) ) ) { + if ( mIsMonospace && mEnableDynamicMonospace ) { + mIsMonospaceComplete = false; + mUsingFallback = true; + } + return systemFallback->getGlyphByIndex( + idx, characterSize, bold, italic, outlineThickness, getPage( characterSize ) ); + } + } + } + return getGlyphByIndex( idx, characterSize, bold, italic, outlineThickness ); } @@ -746,6 +783,26 @@ GlyphDrawable* FontTrueType::getGlyphDrawable( Uint32 codePoint, unsigned int ch glyphIndex = getGlyphIndex( codePoint ); } + if ( 0 == glyphIndex && mEnableSystemFallback && SystemFontResolver::existsSingleton() ) { + FontDesc fallbackDesc = SystemFontResolver::instance()->getFallbackForCodepoint( + codePoint, FontWeight::Normal, false ); + if ( !fallbackDesc.path.empty() ) { + FontTrueType* systemFallback = + FontManager::instance()->getOrLoadSystemFallbackFont( fallbackDesc ); + if ( systemFallback && + ( tGlyphIndex = systemFallback->getGlyphIndex( codePoint ) ) ) { + glyphIndex = tGlyphIndex; + fontInternalId = systemFallback->getFontInternalId(); + if ( mIsMonospace && mEnableDynamicMonospace ) { + mIsMonospaceComplete = false; + mUsingFallback = true; + } + } + } + if ( 0 == glyphIndex ) + glyphIndex = getGlyphIndex( codePoint ); + } + mKeyCache[codePointKey] = { fontInternalId, glyphIndex, isItalic }; } @@ -1059,6 +1116,7 @@ void FontTrueType::cleanup() { mStroker = NULL; mHBFont = NULL; mStreamRec = NULL; + mFaceIndex = 0; mInfo = Info(); mFontInternalId = 0; mBoldAdvanceSameAsRegular = false; @@ -1072,6 +1130,7 @@ void FontTrueType::cleanup() { mUsingFallback = false; mEnableEmojiFallback = true; mEnableFallbackFont = true; + mEnableSystemFallback = true; mEnableDynamicMonospace = false; mIsBold = false; mIsItalic = false; @@ -1624,6 +1683,14 @@ void FontTrueType::setEnableFallbackFont( bool enableFallbackFont ) { mEnableFallbackFont = enableFallbackFont; } +bool FontTrueType::isSystemFallbackEnabled() const { + return mEnableSystemFallback; +} + +void FontTrueType::setEnableSystemFallback( bool enableSystemFallback ) { + mEnableSystemFallback = enableSystemFallback; +} + bool FontTrueType::isEmojiFallbackEnabled() const { return mEnableEmojiFallback; } diff --git a/src/eepp/graphics/systemfontresolver.cpp b/src/eepp/graphics/systemfontresolver.cpp new file mode 100644 index 000000000..70ece7242 --- /dev/null +++ b/src/eepp/graphics/systemfontresolver.cpp @@ -0,0 +1,1006 @@ +#include +#include +#include +#include +#include + +#include +#include + +#if EE_PLATFORM == EE_PLATFORM_WIN +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#pragma comment( lib, "dwrite.lib" ) +#endif + +#if EE_PLATFORM == EE_PLATFORM_MACOS || EE_PLATFORM == EE_PLATFORM_IOS +#include +#include +#endif + +#if EE_PLATFORM == EE_PLATFORM_LINUX || EE_PLATFORM == EE_PLATFORM_BSD +#include +#endif + +#if EE_PLATFORM == EE_PLATFORM_ANDROID +#if __ANDROID_API__ >= 29 +#include +#include +#endif +#include +#endif + +#if EE_PLATFORM == EE_PLATFORM_HAIKU +#include +#include +#include +#include +#include +#endif + +#include +#include FT_FREETYPE_H + +namespace EE { namespace Graphics { + +SINGLETON_DECLARE_IMPLEMENTATION( SystemFontResolver ) + +bool SystemFontResolver::sEnabled = false; + +SystemFontResolver::SystemFontResolver() {} + +SystemFontResolver::~SystemFontResolver() { + invalidateCache(); +} + +void SystemFontResolver::setEnabled( bool enabled ) { + sEnabled = enabled; +} + +bool SystemFontResolver::isEnabled() { + return sEnabled; +} + +void SystemFontResolver::invalidateCache() { + mResolveCache.clear(); + mGenericCache.clear(); + mCodepointFallbackCache.clear(); + mFontList.clear(); + mGenericFallbacks.clear(); + mFontListPopulated = false; +} + +static std::string normalizeFamily( const std::string& family ) { + return String::toLower( String::trim( family ) ); +} + +static const char* genericFamilyStrings[] = { "", "serif", "sans-serif", "monospace", + "cursive", "fantasy", "system-ui", "emoji" }; + +GenericFamily SystemFontResolver::genericFamilyFromName( const std::string& name ) { + std::string lower = String::toLower( String::trim( name ) ); + for ( int i = 1; i <= static_cast( GenericFamily::Emoji ); ++i ) { + if ( lower == genericFamilyStrings[i] ) + return static_cast( i ); + } + return GenericFamily::None; +} + +void SystemFontResolver::ensureFontListPopulated() const { + if ( !mFontListPopulated ) { + populateFontList(); + populateGenericFallbacks(); + mFontListPopulated = true; + } +} + +const std::vector& SystemFontResolver::enumerate() { + ensureFontListPopulated(); + return mFontList; +} + +std::vector SystemFontResolver::enumerateFamily( const std::string& family ) { + ensureFontListPopulated(); + std::vector result; + std::string normFamily = normalizeFamily( family ); + for ( const auto& desc : mFontList ) { + if ( normalizeFamily( desc.family ) == normFamily ) + result.push_back( desc ); + } + return result; +} + +Uint64 SystemFontResolver::makeCacheKey( const std::string& normFamily, FontWeight weight, + FontStretch stretch, bool italic ) { + return ( static_cast( String::hash( normFamily ) ) << 32 ) | + ( static_cast( static_cast( weight ) ) << 16 ) | + ( static_cast( static_cast( stretch ) ) << 8 ) | + ( static_cast( italic ) ); +} + +int SystemFontResolver::scoreMatch( const FontQuery& query, const FontDesc& candidate ) { + int score = 0; + bool familyMatched = false; + + std::string qFamily = normalizeFamily( query.family ); + std::string cFamily = normalizeFamily( candidate.family ); + + if ( cFamily.empty() ) + return -1; + + if ( qFamily.empty() ) + return -1; + + if ( cFamily == qFamily ) { + score -= 100; + familyMatched = true; + } else if ( String::startsWith( cFamily, qFamily ) ) { + score -= 60; + familyMatched = true; + } else if ( String::startsWith( qFamily, cFamily ) ) { + score -= 50; + familyMatched = true; + } else { + score += 200; + } + + if ( candidate.monospace && qFamily == "monospace" ) + score -= 50; + + int weightDiff = + eeabs( static_cast( candidate.weight ) - static_cast( query.weight ) ); + score += weightDiff / 100; + + int stretchDiff = + eeabs( static_cast( candidate.stretch ) - static_cast( query.stretch ) ); + score += stretchDiff * 10; + + if ( candidate.italic == query.italic ) + score -= 30; + else + score += 40; + + return familyMatched ? score : -1; +} + +FontDesc SystemFontResolver::resolve( const FontQuery& query ) { + if ( query.family.empty() ) + return FontDesc(); + + ensureFontListPopulated(); + + std::string normFamily = normalizeFamily( query.family ); + + Uint64 key = makeCacheKey( normFamily, query.weight, query.stretch, query.italic ); + + auto cacheIt = mResolveCache.find( key ); + if ( cacheIt != mResolveCache.end() ) + return cacheIt->second; + + GenericFamily generic = genericFamilyFromName( query.family ); + if ( generic != GenericFamily::None ) { + FontDesc result = resolveGeneric( generic, query.weight, query.italic ); + mResolveCache[key] = result; + return result; + } + + FontDesc best; + int bestScore = 100000; + + for ( const auto& desc : mFontList ) { + int score = scoreMatch( query, desc ); + if ( score != -1 && score < bestScore ) { + bestScore = score; + best = desc; + } + } + + if ( bestScore == 100000 ) + mResolveCache[key] = FontDesc(); + else + mResolveCache[key] = best; + return mResolveCache[key]; +} + +FontDesc SystemFontResolver::resolveFromNamesList( const std::string& namesList, FontWeight weight, + bool italic ) { + ensureFontListPopulated(); + + FontDesc result; + String::readBySeparatorStoppable( + namesList, + [&result, weight, italic]( std::string_view name ) { + name = String::trim( name, ' ' ); + name = String::trim( name, '\'' ); + name = String::trim( name, '"' ); + FontQuery query; + query.family = std::string{ name }; + query.weight = weight; + query.italic = italic; + result = SystemFontResolver::instance()->resolve( query ); + return !result.path.empty(); + }, + ',' ); + + return result; +} + +FontDesc SystemFontResolver::resolveGeneric( GenericFamily generic, FontWeight weight, + bool italic ) { + ensureFontListPopulated(); + + Uint32 cacheKey = ( static_cast( generic ) << 16 ) | + ( static_cast( weight ) << 1 ) | ( italic ? 1 : 0 ); + + auto it = mGenericCache.find( cacheKey ); + if ( it != mGenericCache.end() ) + return it->second; + + FontDesc result; + + for ( const auto& entry : mGenericFallbacks ) { + if ( entry.generic == generic ) { + int entryWeight = static_cast( entry.desc.weight ); + int queryWeight = static_cast( weight ); + int weightDiff = eeabs( entryWeight - queryWeight ); + bool styleMatch = entry.desc.italic == italic; + int bestWeightDiff = result.path.empty() + ? 100000 + : eeabs( static_cast( result.weight ) - + static_cast( weight ) ); + bool bestStyleMatch = + result.path.empty() ? false : ( result.italic == italic ); + + if ( styleMatch && !bestStyleMatch ) { + result = entry.desc; + } else if ( styleMatch == bestStyleMatch && + ( result.path.empty() || weightDiff < bestWeightDiff ) ) { + result = entry.desc; + } + } + } + + mGenericCache[cacheKey] = result; + return result; +} + +FontDesc SystemFontResolver::getSystemFont() const { + if ( !mGenericFallbacks.empty() ) { + for ( const auto& entry : mGenericFallbacks ) { + if ( entry.generic == GenericFamily::SystemUi ) + return entry.desc; + } + } + return FontDesc(); +} + +FontDesc SystemFontResolver::getSystemMonospaceFont() const { + if ( !mGenericFallbacks.empty() ) { + for ( const auto& entry : mGenericFallbacks ) { + if ( entry.generic == GenericFamily::Monospace ) + return entry.desc; + } + } + return FontDesc(); +} + +FontDesc SystemFontResolver::getFallbackForCodepoint( Uint32 codepoint, FontWeight weight, + bool italic ) { + ensureFontListPopulated(); + + Uint32 cacheKey = codepoint; + auto it = mCodepointFallbackCache.find( cacheKey ); + if ( it != mCodepointFallbackCache.end() ) { + const std::string& path = it->second; + if ( path.empty() ) + return FontDesc(); + for ( const auto& desc : mFontList ) { + if ( desc.path == path ) { + FontDesc result = desc; + result.weight = weight; + result.italic = italic; + return result; + } + } + } + + for ( const auto& desc : mFontList ) { + if ( fontContainsCodepoint( desc.path, codepoint ) ) { + mCodepointFallbackCache[cacheKey] = desc.path; + FontDesc result = desc; + result.weight = weight; + result.italic = italic; + return result; + } + } + + mCodepointFallbackCache[cacheKey] = ""; + return FontDesc(); +} + +bool SystemFontResolver::fontContainsCodepoint( const std::string& path, Uint32 codepoint ) { + if ( !FileSystem::fileExists( path ) ) + return false; + + FT_Library ftLib; + if ( FT_Init_FreeType( &ftLib ) != 0 ) + return false; + + FT_Face face; + if ( FT_New_Face( ftLib, path.c_str(), 0, &face ) != 0 ) { + FT_Done_FreeType( ftLib ); + return false; + } + + bool hasGlyph = FT_Get_Char_Index( face, codepoint ) != 0; + + FT_Done_Face( face ); + FT_Done_FreeType( ftLib ); + return hasGlyph; +} + +void SystemFontResolver::populateGenericFallbacks() const { + mGenericFallbacks.clear(); + + struct Mapping { + GenericFamily generic; + const char* family; + }; + + static const Mapping mappings[] = { + { GenericFamily::Serif, "times new roman" }, + { GenericFamily::Serif, "times" }, + { GenericFamily::Serif, "dejavu serif" }, + { GenericFamily::Serif, "liberation serif" }, + { GenericFamily::Serif, "noto serif" }, + { GenericFamily::Serif, "serif" }, + { GenericFamily::SansSerif, "arial" }, + { GenericFamily::SansSerif, "helvetica" }, + { GenericFamily::SansSerif, "dejavu sans" }, + { GenericFamily::SansSerif, "liberation sans" }, + { GenericFamily::SansSerif, "roboto" }, + { GenericFamily::SansSerif, "noto sans" }, + { GenericFamily::SansSerif, "sans-serif" }, + { GenericFamily::Monospace, "consolas" }, + { GenericFamily::Monospace, "menlo" }, + { GenericFamily::Monospace, "dejavu sans mono" }, + { GenericFamily::Monospace, "liberation mono" }, + { GenericFamily::Monospace, "droid sans mono" }, + { GenericFamily::Monospace, "noto sans mono" }, + { GenericFamily::Monospace, "monospace" }, + { GenericFamily::Cursive, "comic sans ms" }, + { GenericFamily::Cursive, "apple chancery" }, + { GenericFamily::Cursive, "cursive" }, + { GenericFamily::Fantasy, "impact" }, + { GenericFamily::Fantasy, "papyrus" }, + { GenericFamily::Fantasy, "fantasy" }, + { GenericFamily::SystemUi, "segoe ui" }, + { GenericFamily::SystemUi, "sf pro" }, + { GenericFamily::SystemUi, "dejavu sans" }, + { GenericFamily::SystemUi, "system-ui" }, + { GenericFamily::Emoji, "segoe ui emoji" }, + { GenericFamily::Emoji, "apple color emoji" }, + { GenericFamily::Emoji, "noto color emoji" }, + { GenericFamily::Emoji, "emoji" }, + }; + + for ( const auto& mapping : mappings ) { + std::string searchFamily = normalizeFamily( mapping.family ); + for ( const auto& desc : mFontList ) { + std::string descFamily = normalizeFamily( desc.family ); + if ( descFamily == searchFamily ) { + mGenericFallbacks.push_back( { mapping.generic, desc } ); + } + } + } +} + +// ===================================================================== +// Platform: Windows (DirectWrite) +// ===================================================================== +#if EE_PLATFORM == EE_PLATFORM_WIN + +static std::string wideToUtf8( const WCHAR* wstr ) { + if ( !wstr || !*wstr ) + return {}; + int len = WideCharToMultiByte( CP_UTF8, 0, wstr, -1, nullptr, 0, nullptr, nullptr ); + if ( len <= 0 ) + return {}; + std::string result( len - 1, '\0' ); + WideCharToMultiByte( CP_UTF8, 0, wstr, -1, &result[0], len, nullptr, nullptr ); + return result; +} + +static IDWriteFactory* getDWriteFactory() { + static IDWriteFactory* sFactory = nullptr; + if ( !sFactory ) { + HRESULT hr = DWriteCreateFactory( DWRITE_FACTORY_TYPE_SHARED, __uuidof( IDWriteFactory ), + reinterpret_cast( &sFactory ) ); + if ( FAILED( hr ) ) + return nullptr; + } + return sFactory; +} + +void SystemFontResolver::populateFontList() const { + IDWriteFactory* factory = getDWriteFactory(); + if ( !factory ) + return; + + IDWriteFontCollection* collection = nullptr; + HRESULT hr = factory->GetSystemFontCollection( &collection, FALSE ); + if ( FAILED( hr ) || !collection ) + return; + + UINT32 familyCount = collection->GetFontFamilyCount(); + + for ( UINT32 i = 0; i < familyCount; ++i ) { + IDWriteFontFamily* fontFamily = nullptr; + hr = collection->GetFontFamily( i, &fontFamily ); + if ( FAILED( hr ) || !fontFamily ) + continue; + + IDWriteLocalizedStrings* familyNames = nullptr; + hr = fontFamily->GetFamilyNames( &familyNames ); + if ( FAILED( hr ) || !familyNames ) { + fontFamily->Release(); + continue; + } + + UINT32 nameLen = 0; + hr = familyNames->GetStringLength( 0, &nameLen ); + if ( FAILED( hr ) ) { + familyNames->Release(); + fontFamily->Release(); + continue; + } + + std::wstring familyNameW( nameLen + 1, L'\0' ); + familyNames->GetString( 0, &familyNameW[0], nameLen + 1 ); + familyNames->Release(); + + std::string familyName = wideToUtf8( familyNameW.c_str() ); + if ( familyName.empty() ) { + fontFamily->Release(); + continue; + } + + UINT32 fontCount = fontFamily->GetFontCount(); + for ( UINT32 j = 0; j < fontCount; ++j ) { + IDWriteFont* dwriteFont = nullptr; + hr = fontFamily->GetFont( j, &dwriteFont ); + if ( FAILED( hr ) || !dwriteFont ) + continue; + + BOOL isSymbol = dwriteFont->IsSymbolFont(); + DWRITE_FONT_WEIGHT dwWeight = dwriteFont->GetWeight(); + DWRITE_FONT_STRETCH dwStretch = dwriteFont->GetStretch(); + DWRITE_FONT_STYLE dwStyle = dwriteFont->GetStyle(); + + IDWriteFontFace* fontFace = nullptr; + hr = dwriteFont->CreateFontFace( &fontFace ); + if ( FAILED( hr ) || !fontFace ) { + dwriteFont->Release(); + continue; + } + + UINT32 fileCount = 0; + hr = fontFace->GetFiles( &fileCount, nullptr ); + if ( FAILED( hr ) || fileCount == 0 ) { + fontFace->Release(); + dwriteFont->Release(); + continue; + } + + std::vector files( fileCount ); + hr = fontFace->GetFiles( &fileCount, files.data() ); + if ( FAILED( hr ) ) { + fontFace->Release(); + dwriteFont->Release(); + continue; + } + + for ( UINT32 k = 0; k < fileCount; ++k ) { + IDWriteFontFile* fontFile = files[k]; + IDWriteFontFileLoader* loader = nullptr; + hr = fontFile->GetLoader( &loader ); + if ( FAILED( hr ) || !loader ) { + fontFile->Release(); + continue; + } + + IDWriteLocalFontFileLoader* localLoader = nullptr; + hr = loader->QueryInterface( __uuidof( IDWriteLocalFontFileLoader ), + reinterpret_cast( &localLoader ) ); + loader->Release(); + + if ( FAILED( hr ) || !localLoader ) { + fontFile->Release(); + continue; + } + + const void* refKey = nullptr; + UINT32 refKeySize = 0; + hr = fontFile->GetReferenceKey( &refKey, &refKeySize ); + if ( FAILED( hr ) ) { + localLoader->Release(); + fontFile->Release(); + continue; + } + + UINT32 pathLen = 0; + hr = localLoader->GetFilePathLengthFromKey( refKey, refKeySize, &pathLen ); + if ( FAILED( hr ) ) { + localLoader->Release(); + fontFile->Release(); + continue; + } + + std::wstring filePathW( pathLen + 1, L'\0' ); + hr = localLoader->GetFilePathFromKey( refKey, refKeySize, &filePathW[0], + pathLen + 1 ); + localLoader->Release(); + fontFile->Release(); + + if ( FAILED( hr ) ) + continue; + + std::string filePath = wideToUtf8( filePathW.c_str() ); + if ( filePath.empty() ) + continue; + + if ( isSymbol ) + continue; + + FontDesc desc; + desc.family = familyName; + desc.path = filePath; + desc.faceIndex = j; + desc.weight = static_cast( static_cast( dwWeight ) ); + desc.stretch = static_cast( static_cast( dwStretch ) ); + desc.italic = + ( dwStyle == DWRITE_FONT_STYLE_ITALIC || dwStyle == DWRITE_FONT_STYLE_OBLIQUE ); + + DWRITE_PANOSE panose; + dwriteFont->GetPanose( &panose ); + desc.monospace = ( panose.familyKind == 2 && panose.text.panoseProportion == 9 ); + + mFontList.push_back( desc ); + } + + fontFace->Release(); + dwriteFont->Release(); + } + + fontFamily->Release(); + } + + collection->Release(); +} + +// ===================================================================== +// Platform: macOS / iOS (Core Text) +// ===================================================================== +#elif EE_PLATFORM == EE_PLATFORM_MACOS || EE_PLATFORM == EE_PLATFORM_IOS + +static std::string cfStringToStd( CFStringRef str ) { + if ( !str ) + return {}; + CFIndex len = CFStringGetLength( str ); + CFIndex maxLen = CFStringGetMaximumSizeForEncoding( len, kCFStringEncodingUTF8 ); + std::string result( maxLen, '\0' ); + CFStringGetCString( str, &result[0], maxLen, kCFStringEncodingUTF8 ); + result.resize( strlen( result.c_str() ) ); + return result; +} + +static FontWeight ctWeightToFontWeight( CGFloat weight ) { + if ( weight <= -0.8 ) + return FontWeight::Thin; + if ( weight <= -0.6 ) + return FontWeight::ExtraLight; + if ( weight <= -0.4 ) + return FontWeight::Light; + if ( weight <= 0.0 ) + return FontWeight::Normal; + if ( weight <= 0.2 ) + return FontWeight::Medium; + if ( weight <= 0.3 ) + return FontWeight::SemiBold; + if ( weight <= 0.4 ) + return FontWeight::Bold; + if ( weight <= 0.6 ) + return FontWeight::ExtraBold; + return FontWeight::Black; +} + +static FontStretch ctWidthToFontStretch( CGFloat width ) { + if ( width <= -0.8 ) + return FontStretch::UltraCondensed; + if ( width <= -0.6 ) + return FontStretch::ExtraCondensed; + if ( width <= -0.4 ) + return FontStretch::Condensed; + if ( width <= -0.2 ) + return FontStretch::SemiCondensed; + if ( width <= 0.1 ) + return FontStretch::Normal; + if ( width <= 0.4 ) + return FontStretch::SemiExpanded; + if ( width <= 0.6 ) + return FontStretch::Expanded; + if ( width <= 0.8 ) + return FontStretch::ExtraExpanded; + return FontStretch::UltraExpanded; +} + +void SystemFontResolver::populateFontList() const { + CFArrayRef descriptors = CTFontManagerCopyAvailableFontFamilyNames(); + if ( !descriptors ) + return; + + CFIndex count = CFArrayGetCount( descriptors ); + + for ( CFIndex i = 0; i < count; ++i ) { + CFStringRef familyNameRef = (CFStringRef)CFArrayGetValueAtIndex( descriptors, i ); + std::string familyName = cfStringToStd( familyNameRef ); + if ( familyName.empty() ) + continue; + + CFMutableDictionaryRef queryDict = + CFDictionaryCreateMutable( kCFAllocatorDefault, 1, &kCFTypeDictionaryKeyCallBacks, + &kCFTypeDictionaryValueCallBacks ); + CFDictionaryAddValue( queryDict, kCTFontFamilyNameAttribute, familyNameRef ); + + CTFontDescriptorRef familyDesc = CTFontDescriptorCreateWithAttributes( queryDict ); + CFRelease( queryDict ); + + CFSetRef mandatoryAttrs = CFSetCreate( + kCFAllocatorDefault, (const void**)&kCTFontURLAttribute, 1, &kCFTypeSetCallBacks ); + + CFArrayRef matchingDescs = + CTFontDescriptorCreateMatchingFontDescriptors( familyDesc, mandatoryAttrs ); + CFRelease( mandatoryAttrs ); + CFRelease( familyDesc ); + + if ( !matchingDescs ) { + CFRelease( descriptors ); + return; + } + + CFIndex matchCount = CFArrayGetCount( matchingDescs ); + + for ( CFIndex j = 0; j < matchCount; ++j ) { + CTFontDescriptorRef desc = + (CTFontDescriptorRef)CFArrayGetValueAtIndex( matchingDescs, j ); + + CFURLRef url = (CFURLRef)CTFontDescriptorCopyAttribute( desc, kCTFontURLAttribute ); + if ( !url ) + continue; + + CFStringRef pathRef = CFURLCopyFileSystemPath( url, kCFURLPOSIXPathStyle ); + CFRelease( url ); + + std::string fontPath = cfStringToStd( pathRef ); + CFRelease( pathRef ); + + if ( fontPath.empty() ) + continue; + + CFNumberRef weightNum = + (CFNumberRef)CTFontDescriptorCopyAttribute( desc, kCTFontWeightTrait ); + CGFloat weightVal = 0.0; + if ( weightNum ) { + CFNumberGetValue( weightNum, kCFNumberCGFloatType, &weightVal ); + CFRelease( weightNum ); + } + + CFNumberRef widthNum = + (CFNumberRef)CTFontDescriptorCopyAttribute( desc, kCTFontWidthTrait ); + CGFloat widthVal = 0.0; + if ( widthNum ) { + CFNumberGetValue( widthNum, kCFNumberCGFloatType, &widthVal ); + CFRelease( widthNum ); + } + + CFNumberRef slantNum = + (CFNumberRef)CTFontDescriptorCopyAttribute( desc, kCTFontSlantTrait ); + CGFloat slantVal = 0.0; + if ( slantNum ) { + CFNumberGetValue( slantNum, kCFNumberCGFloatType, &slantVal ); + CFRelease( slantNum ); + } + + CFNumberRef monoNum = + (CFNumberRef)CTFontDescriptorCopyAttribute( desc, kCTFontMonoSpaceTrait ); + int isMono = 0; + if ( monoNum ) { + CFNumberGetValue( monoNum, kCFNumberIntType, &isMono ); + CFRelease( monoNum ); + } + + FontDesc fontDesc; + fontDesc.family = familyName; + fontDesc.path = fontPath; + fontDesc.faceIndex = static_cast( j ); + fontDesc.weight = ctWeightToFontWeight( weightVal ); + fontDesc.stretch = ctWidthToFontStretch( widthVal ); + fontDesc.italic = ( slantVal > 0.0 ); + fontDesc.monospace = ( isMono != 0 ); + + mFontList.push_back( fontDesc ); + } + + CFRelease( matchingDescs ); + } + + CFRelease( descriptors ); +} + +// ===================================================================== +// Platform: Linux / FreeBSD (Fontconfig) +// ===================================================================== +#elif EE_PLATFORM == EE_PLATFORM_LINUX || EE_PLATFORM == EE_PLATFORM_BSD + +static FontWeight fcWeightToFontWeight( int fcWeight ) { + if ( fcWeight <= FC_WEIGHT_THIN ) + return FontWeight::Thin; + if ( fcWeight <= FC_WEIGHT_EXTRALIGHT ) + return FontWeight::ExtraLight; + if ( fcWeight <= FC_WEIGHT_LIGHT ) + return FontWeight::Light; + if ( fcWeight <= FC_WEIGHT_REGULAR ) + return FontWeight::Normal; + if ( fcWeight <= FC_WEIGHT_MEDIUM ) + return FontWeight::Medium; + if ( fcWeight <= FC_WEIGHT_SEMIBOLD ) + return FontWeight::SemiBold; + if ( fcWeight <= FC_WEIGHT_BOLD ) + return FontWeight::Bold; + if ( fcWeight <= FC_WEIGHT_EXTRABOLD ) + return FontWeight::ExtraBold; + return FontWeight::Black; +} + +static FontStretch fcWidthToFontStretch( int fcWidth ) { + if ( fcWidth <= FC_WIDTH_ULTRACONDENSED ) + return FontStretch::UltraCondensed; + if ( fcWidth <= FC_WIDTH_EXTRACONDENSED ) + return FontStretch::ExtraCondensed; + if ( fcWidth <= FC_WIDTH_CONDENSED ) + return FontStretch::Condensed; + if ( fcWidth <= FC_WIDTH_SEMICONDENSED ) + return FontStretch::SemiCondensed; + if ( fcWidth <= FC_WIDTH_NORMAL ) + return FontStretch::Normal; + if ( fcWidth <= FC_WIDTH_SEMIEXPANDED ) + return FontStretch::SemiExpanded; + if ( fcWidth <= FC_WIDTH_EXPANDED ) + return FontStretch::Expanded; + if ( fcWidth <= FC_WIDTH_EXTRAEXPANDED ) + return FontStretch::ExtraExpanded; + return FontStretch::UltraExpanded; +} + +void SystemFontResolver::populateFontList() const { + if ( !FcInit() ) + return; + + FcPattern* pattern = FcPatternCreate(); + FcObjectSet* os = FcObjectSetBuild( FC_FAMILY, FC_FILE, FC_INDEX, FC_WEIGHT, FC_WIDTH, FC_SLANT, + FC_SPACING, nullptr ); + + FcFontSet* fontSet = FcFontList( nullptr, pattern, os ); + + FcPatternDestroy( pattern ); + FcObjectSetDestroy( os ); + + if ( !fontSet ) + return; + + for ( int i = 0; i < fontSet->nfont; ++i ) { + FcPattern* font = fontSet->fonts[i]; + + FcChar8* family = nullptr; + if ( FcPatternGetString( font, FC_FAMILY, 0, &family ) != FcResultMatch || !family ) + continue; + + FcChar8* file = nullptr; + if ( FcPatternGetString( font, FC_FILE, 0, &file ) != FcResultMatch || !file ) + continue; + + int fcIndex = 0; + FcPatternGetInteger( font, FC_INDEX, 0, &fcIndex ); + + int fcWeight = FC_WEIGHT_REGULAR; + FcPatternGetInteger( font, FC_WEIGHT, 0, &fcWeight ); + + int fcWidth = FC_WIDTH_NORMAL; + FcPatternGetInteger( font, FC_WIDTH, 0, &fcWidth ); + + int fcSlant = FC_SLANT_ROMAN; + FcPatternGetInteger( font, FC_SLANT, 0, &fcSlant ); + + int fcSpacing = FC_MONO; + FcPatternGetInteger( font, FC_SPACING, 0, &fcSpacing ); + + FontDesc desc; + desc.family = reinterpret_cast( family ); + desc.path = reinterpret_cast( file ); + desc.faceIndex = fcIndex >= 0 ? static_cast( fcIndex ) : 0; + desc.weight = fcWeightToFontWeight( fcWeight ); + desc.stretch = fcWidthToFontStretch( fcWidth ); + desc.italic = ( fcSlant == FC_SLANT_ITALIC || fcSlant == FC_SLANT_OBLIQUE ); + desc.monospace = ( fcSpacing == FC_MONO ); + + mFontList.push_back( desc ); + } + + FcFontSetDestroy( fontSet ); +} + +// ===================================================================== +// Platform: Android (NDK Font API + XML fallback) +// ===================================================================== +#elif EE_PLATFORM == EE_PLATFORM_ANDROID + +#if __ANDROID_API__ >= 29 + +void SystemFontResolver::populateFontList() const { + AFontMatcher* matcher = AFontMatcher_create(); + if ( !matcher ) { + Log::warning( "SystemFontResolver: AFontMatcher_create failed, trying XML fallback" ); + goto xml_fallback; + } + + // Use the NDK font matcher to get system fonts + // The AFontMatcher API provides only matching, not full enumeration. + // Fall back to XML parsing for complete enumeration. + + AFontMatcher_destroy( matcher ); + goto xml_fallback; + +xml_fallback: + // XML fallback below + populateFontListXml(); +} + +#else + +void SystemFontResolver::populateFontList() const { + populateFontListXml(); +} + +#endif + +void SystemFontResolver::populateFontListXml() const { + static const char* fontPaths[] = { "/system/etc/fonts.xml", "/system/fonts/fonts.xml", + "/vendor/etc/fonts.xml", nullptr }; + + const char* xmlPath = nullptr; + for ( int i = 0; fontPaths[i]; ++i ) { + if ( FileSystem::fileExists( fontPaths[i] ) ) { + xmlPath = fontPaths[i]; + break; + } + } + + if ( !xmlPath ) + return; + + pugi::xml_document doc; + pugi::xml_parse_result result = doc.load_file( xmlPath ); + if ( !result ) + return; + + pugi::xml_node families = doc.child( "familyset" ); + if ( !families ) + return; + + for ( pugi::xml_node familyNode : families.children( "family" ) ) { + std::string familyName = familyNode.attribute( "name" ).as_string(); + if ( familyName.empty() ) + continue; + + for ( pugi::xml_node fontNode : familyNode.children( "font" ) ) { + std::string fontFile = fontNode.text().as_string(); + if ( fontFile.empty() ) + continue; + + std::string fontPath = "/system/fonts/" + fontFile; + if ( !FileSystem::fileExists( fontPath ) ) + fontPath = fontFile; + + std::string weightStr = fontNode.attribute( "weight" ).as_string(); + FontWeight weight = FontWeight::Normal; + if ( !weightStr.empty() ) + weight = static_cast( std::atoi( weightStr.c_str() ) ); + + std::string styleStr = fontNode.attribute( "style" ).as_string(); + bool italic = ( styleStr == "italic" ); + + FontDesc desc; + desc.family = familyName; + desc.path = fontPath; + desc.faceIndex = 0; + desc.weight = weight; + desc.italic = italic; + + mFontList.push_back( desc ); + } + } +} + +// ===================================================================== +// Platform: Haiku (BFont) +// ===================================================================== +#elif EE_PLATFORM == EE_PLATFORM_HAIKU + +void SystemFontResolver::populateFontList() const { + static const char* fontDirs[] = { "/system/data/fonts", "/system/non-packaged/data/fonts", + nullptr }; + + for ( int d = 0; fontDirs[d]; ++d ) { + BDirectory dir( fontDirs[d] ); + if ( dir.InitCheck() != B_OK ) + continue; + + BEntry entry; + while ( dir.GetNextEntry( &entry, true ) == B_OK ) { + BPath path; + if ( entry.GetPath( &path ) != B_OK ) + continue; + + std::string pathStr( path.Path() ); + if ( pathStr.empty() ) + continue; + + std::string ext = FileSystem::fileExtension( pathStr ); + if ( ext != "ttf" && ext != "otf" && ext != "ttc" && ext != "otc" && ext != "pfa" && + ext != "pfb" && ext != "dfont" ) + continue; + + char nameBuf[B_FONT_FAMILY_LENGTH + 1] = {}; + char styleBuf[B_FONT_STYLE_LENGTH + 1] = {}; + bool isMono = false; + + BFont font; + if ( font.SetFamilyAndStyle( nullptr, nullptr ) == B_OK ) { + font_family family; + font_style style; + font.GetFamilyAndStyle( &family, &style ); + + FontDesc desc; + desc.family = family; + desc.path = pathStr; + desc.faceIndex = 0; + desc.weight = FontWeight::Normal; + + std::string styleStr( style ); + if ( styleStr.find( "Bold" ) != std::string::npos || + styleStr.find( "bold" ) != std::string::npos ) + desc.weight = FontWeight::Bold; + if ( styleStr.find( "Italic" ) != std::string::npos || + styleStr.find( "italic" ) != std::string::npos || + styleStr.find( "Oblique" ) != std::string::npos ) + desc.italic = true; + + mFontList.push_back( desc ); + } + } + } +} + +// ===================================================================== +// Platform: Emscripten / Unknown (no system font access) +// ===================================================================== +#else + +void SystemFontResolver::populateFontList() const {} + +#endif + +}} // namespace EE::Graphics diff --git a/src/eepp/ui/css/stylesheetspecification.cpp b/src/eepp/ui/css/stylesheetspecification.cpp index 8074f5b1d..d38cae58a 100644 --- a/src/eepp/ui/css/stylesheetspecification.cpp +++ b/src/eepp/ui/css/stylesheetspecification.cpp @@ -1,3 +1,4 @@ +#include #include #include #include @@ -1348,8 +1349,26 @@ void StyleSheetSpecification::registerDefaultShorthandParsers() { static const std::string systemFonts[] = { "caption", "icon", "menu", "message-box", "small-caption", "status-bar" }; for ( const auto& sysFont : systemFonts ) { - if ( lowerVal == sysFont ) - return {}; + if ( lowerVal == sysFont ) { + std::vector properties; + const std::vector& propNames = shorthand->getProperties(); + int familyPos = getIndexEndingWith( propNames, "-family" ); + int stylePos = getIndexEndingWith( propNames, "-style" ); + if ( familyPos != -1 && Graphics::SystemFontResolver::isEnabled() ) { + Graphics::FontDesc desc = + Graphics::SystemFontResolver::instance()->resolveGeneric( + Graphics::GenericFamily::SystemUi, Graphics::FontWeight::Normal, + false ); + if ( !desc.family.empty() ) { + properties.emplace_back( + StyleSheetProperty( propNames[familyPos], desc.family ) ); + if ( stylePos != -1 ) + properties.emplace_back( StyleSheetProperty( + propNames[stylePos], desc.italic ? "italic" : "normal" ) ); + } + } + return properties; + } } std::vector properties; diff --git a/src/eepp/ui/uicodeeditor.cpp b/src/eepp/ui/uicodeeditor.cpp index ebf1bd211..ee1ec9580 100644 --- a/src/eepp/ui/uicodeeditor.cpp +++ b/src/eepp/ui/uicodeeditor.cpp @@ -2923,6 +2923,10 @@ UICodeEditor* UICodeEditor::setFontStyle( const Uint32& fontStyle ) { mFontStyleConfig.Style = fontStyle; invalidateDraw(); onFontStyleChanged(); + + if ( auto* newFont = + getUISceneNode()->reevaluateFontStyle( mFontStyleConfig.Font, fontStyle ) ) + setFont( newFont ); } return this; @@ -3013,7 +3017,8 @@ bool UICodeEditor::applyProperty( const StyleSheetProperty& attribute ) { setFontSelectionBackColor( attribute.asColor() ); break; case PropertyId::FontFamily: { - Font* font = getUISceneNode()->getFontFromNamesList( attribute.value() ); + Font* font = + getUISceneNode()->getFontFromNamesList( attribute.value(), getFontStyle() ); if ( NULL != font && font->loaded() ) { setFont( font ); } diff --git a/src/eepp/ui/uiconsole.cpp b/src/eepp/ui/uiconsole.cpp index 7380e75a9..2cdec1556 100644 --- a/src/eepp/ui/uiconsole.cpp +++ b/src/eepp/ui/uiconsole.cpp @@ -186,7 +186,7 @@ bool UIConsole::applyProperty( const StyleSheetProperty& attribute ) { setFontSelectionBackColor( attribute.asColor() ); break; case PropertyId::FontFamily: { - Font* font = getUISceneNode()->getFontFromNamesList( attribute.value() ); + Font* font = getUISceneNode()->getFontFromNamesList( attribute.value(), getFontStyleConfig().getFontStyle() ); if ( NULL != font && font->loaded() ) { setFont( font ); diff --git a/src/eepp/ui/uirichtext.cpp b/src/eepp/ui/uirichtext.cpp index 8ca72ba4c..e002e86ef 100644 --- a/src/eepp/ui/uirichtext.cpp +++ b/src/eepp/ui/uirichtext.cpp @@ -518,6 +518,10 @@ UIRichText* UIRichText::setFontStyle( const Uint32& fontStyle ) { notifyLayoutAttrChange(); notifyLayoutAttrChangeParent(); updateDefaultSpansStyle(); + + if ( auto* newFont = getUISceneNode()->reevaluateFontStyle( + mRichText.getFontStyleConfig().Font, fontStyle ) ) + setFont( newFont ); } return this; } diff --git a/src/eepp/ui/uiscenenode.cpp b/src/eepp/ui/uiscenenode.cpp index 3d88b1cac..aab4c5b80 100644 --- a/src/eepp/ui/uiscenenode.cpp +++ b/src/eepp/ui/uiscenenode.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #include #include @@ -1515,6 +1516,7 @@ Font* UISceneNode::getFontFromNamesList( std::string_view names, Uint32 fontStyl [&]( std::string_view name ) { name = String::trim( name, ' ' ); name = String::trim( name, '\'' ); + name = String::trim( name, '"' ); std::string fontFamily{ name }; if ( fontStyle ) fontFamily += "#" + Text::styleFlagToString( fontStyle ); @@ -1522,7 +1524,113 @@ Font* UISceneNode::getFontFromNamesList( std::string_view names, Uint32 fontStyl return font != nullptr; }, ',' ); + + if ( !font && Graphics::SystemFontResolver::isEnabled() ) { + Graphics::FontWeight weight = + ( fontStyle & Text::Bold ) ? Graphics::FontWeight::Bold : Graphics::FontWeight::Normal; + Graphics::FontDesc desc = Graphics::SystemFontResolver::instance()->resolveFromNamesList( + std::string{ names }, weight, fontStyle & Text::Italic ); + if ( !desc.path.empty() ) { + std::string family = desc.family; + if ( fontStyle ) + family += "#" + Text::styleFlagToString( fontStyle ); + FontTrueType* ttf = FontTrueType::New( family, desc.path, desc.faceIndex ); + if ( ttf && ttf->loaded() ) { + font = ttf; + + Uint32 weightStyle = fontStyle & ( Text::Bold | Text::Italic ); + if ( weightStyle ) { + Font* regular = + FontManager::instance()->getByName( desc.family ); + if ( regular && regular != font && + regular->getType() == FontType::TTF ) { + auto* regularFT = static_cast( regular ); + if ( weightStyle == Text::Bold ) + regularFT->setBoldFont( ttf ); + else if ( weightStyle == Text::Italic ) + regularFT->setItalicFont( ttf ); + else + regularFT->setBoldItalicFont( ttf ); + } + } + } + } + } + return font; } +Font* UISceneNode::reevaluateFontStyle( Font* currentFont, Uint32 fontStyle ) const { + if ( !currentFont || !Graphics::SystemFontResolver::isEnabled() ) + return nullptr; + + if ( currentFont->getType() != FontType::TTF ) + return nullptr; + + std::string name = currentFont->getName(); + size_t pos = name.find( '#' ); + if ( pos != std::string::npos ) + name = name.substr( 0, pos ); + + Uint32 weightStyle = fontStyle & ( Text::Bold | Text::Italic ); + Font* newFont = getFontFromNamesList( name, weightStyle ); + if ( newFont && newFont != currentFont ) + return newFont; + + return nullptr; +} + +void UISceneNode::loadFontStyleVariants( Font* font, const std::string& family ) const { + if ( !font || !Graphics::SystemFontResolver::isEnabled() ) + return; + if ( font->getType() != FontType::TTF ) + return; + auto* ft = static_cast( font ); + + auto loadVariant = [this, family]( FontWeight weight, bool italic ) -> FontTrueType* { + Uint32 style = 0; + if ( italic ) + style |= Text::Italic; + if ( weight == FontWeight::Bold ) + style |= Text::Bold; + std::string queryFamily = family; + if ( style ) + queryFamily += "#" + Text::styleFlagToString( style ); + Font* existing = FontManager::instance()->getByName( queryFamily ); + if ( existing && existing->getType() == FontType::TTF ) + return static_cast( existing ); + + FontDesc desc = SystemFontResolver::instance()->resolveGeneric( + SystemFontResolver::genericFamilyFromName( family ), weight, italic ); + if ( desc.path.empty() ) { + FontQuery query; + query.family = family; + query.weight = weight; + query.italic = italic; + desc = SystemFontResolver::instance()->resolve( query ); + } + if ( desc.path.empty() ) + return nullptr; + + auto* ttf = FontTrueType::New( queryFamily, desc.path, desc.faceIndex ); + if ( !ttf || !ttf->loaded() ) { + eeSAFE_DELETE( ttf ); + return nullptr; + } + return ttf; + }; + + FontTrueType* boldFont = loadVariant( FontWeight::Bold, false ); + if ( boldFont ) + ft->setBoldFont( boldFont ); + + FontTrueType* italicFont = loadVariant( FontWeight::Normal, true ); + if ( italicFont ) + ft->setItalicFont( italicFont ); + + FontTrueType* boldItalicFont = loadVariant( FontWeight::Bold, true ); + if ( boldItalicFont ) + ft->setBoldItalicFont( boldItalicFont ); +} + }} // namespace EE::UI diff --git a/src/eepp/ui/uitextspan.cpp b/src/eepp/ui/uitextspan.cpp index 01e2db182..6162b78b7 100644 --- a/src/eepp/ui/uitextspan.cpp +++ b/src/eepp/ui/uitextspan.cpp @@ -15,8 +15,7 @@ #define PUGIXML_HEADER_ONLY #include -namespace EE { -namespace UI { +namespace EE { namespace UI { UITextSpan* UITextSpan::New() { return eeNew( UITextSpan, () ); @@ -128,7 +127,8 @@ bool UITextSpan::applyProperty( const StyleSheetProperty& attribute ) { setFontShadowOffset( attribute.asVector2f() ); break; case PropertyId::FontFamily: { - Font* font = getUISceneNode()->getFontFromNamesList( attribute.value() ); + Font* font = + getUISceneNode()->getFontFromNamesList( attribute.value(), getFontStyle() ); if ( NULL != font && font->loaded() ) { setFont( font ); } @@ -276,6 +276,10 @@ UITextSpan* UITextSpan::setFontStyle( const Uint32& fontStyle ) { mRichText.invalidate(); onFontStyleChanged(); notifyLayoutAttrChange(); + + if ( auto* newFont = getUISceneNode()->reevaluateFontStyle( + mRichText.getFontStyleConfig().getFont(), fontStyle ) ) + setFont( newFont ); } return this; } @@ -819,5 +823,4 @@ void UILabelSpan::activateTarget() { } } -} -} // namespace EE::UI +}} // namespace EE::UI diff --git a/src/eepp/ui/uitextview.cpp b/src/eepp/ui/uitextview.cpp index 91e848d42..ebcb5de16 100644 --- a/src/eepp/ui/uitextview.cpp +++ b/src/eepp/ui/uitextview.cpp @@ -179,6 +179,10 @@ UITextView* UITextView::setFontStyle( const Uint32& fontStyle ) { onFontStyleChanged(); notifyLayoutAttrChange(); invalidateDraw(); + + if ( auto* newFont = + getUISceneNode()->reevaluateFontStyle( mFontStyleConfig.Font, fontStyle ) ) + setFont( newFont ); } return this; @@ -742,7 +746,8 @@ bool UITextView::applyProperty( const StyleSheetProperty& attribute ) { setSelectionBackColor( attribute.asColor() ); break; case PropertyId::FontFamily: { - Font* font = getUISceneNode()->getFontFromNamesList( attribute.value() ); + Font* font = + getUISceneNode()->getFontFromNamesList( attribute.value(), getFontStyle() ); if ( !mUsingCustomStyling && NULL != font && font->loaded() ) { setFont( font ); } diff --git a/src/eepp/ui/uitooltip.cpp b/src/eepp/ui/uitooltip.cpp index 4d045f9b4..13f090155 100644 --- a/src/eepp/ui/uitooltip.cpp +++ b/src/eepp/ui/uitooltip.cpp @@ -371,6 +371,9 @@ UITooltip* UITooltip::setFontStyle( const Uint32& fontStyle ) { onAutoSize(); autoAlign(); invalidateDraw(); + + if ( auto* newFont = getUISceneNode()->reevaluateFontStyle( mStyleConfig.Font, fontStyle ) ) + setFont( newFont ); } return this; @@ -560,7 +563,8 @@ bool UITooltip::applyProperty( const StyleSheetProperty& attribute ) { setFontShadowOffset( attribute.asVector2f() ); break; case PropertyId::FontFamily: { - Font* font = getUISceneNode()->getFontFromNamesList( attribute.value() ); + Font* font = + getUISceneNode()->getFontFromNamesList( attribute.value(), getFontStyle() ); if ( !mUsingCustomStyling && NULL != font && font->loaded() ) setFont( font ); diff --git a/src/examples/ui_html/ui_html.cpp b/src/examples/ui_html/ui_html.cpp index e216cfe1c..c0dc3b08b 100644 --- a/src/examples/ui_html/ui_html.cpp +++ b/src/examples/ui_html/ui_html.cpp @@ -4,6 +4,7 @@ #include EE_MAIN_FUNC int main( int argc, char** argv ) { + SystemFontResolver::setEnabled( true ); args::ArgumentParser parser( "eepp HTML Example" ); args::HelpFlag help( parser, "help", "Display this help menu", { 'h', "help" } ); diff --git a/src/tests/unit_tests/systemfontresolver_tests.cpp b/src/tests/unit_tests/systemfontresolver_tests.cpp new file mode 100644 index 000000000..da4ab9fd2 --- /dev/null +++ b/src/tests/unit_tests/systemfontresolver_tests.cpp @@ -0,0 +1,536 @@ +#include "utest.hpp" + +#include +#include +#include +#include +#include + +using namespace EE; +using namespace EE::Graphics; +using namespace EE::System; + +static std::string getFontsDir() { + return Sys::getProcessPath() + "../assets/fonts/"; +} + +UTEST( SystemFontResolver, singletonLifecycle ) { + SystemFontResolver::setEnabled( true ); + UTEST_PRINT_STEP( "Create singleton" ); + auto* resolver = SystemFontResolver::createSingleton(); + EXPECT_TRUE( resolver != nullptr ); + EXPECT_TRUE( SystemFontResolver::existsSingleton() != nullptr ); + + UTEST_PRINT_STEP( "Destroy singleton" ); + SystemFontResolver::destroySingleton(); + EXPECT_TRUE( SystemFontResolver::existsSingleton() == nullptr ); + + UTEST_PRINT_STEP( "Re-create via instance()" ); + auto* resolver2 = SystemFontResolver::instance(); + EXPECT_TRUE( resolver2 != nullptr ); + EXPECT_TRUE( SystemFontResolver::existsSingleton() != nullptr ); + + SystemFontResolver::destroySingleton(); + SystemFontResolver::setEnabled( false ); +} + +UTEST( SystemFontResolver, genericFamilyFromName ) { + SystemFontResolver::setEnabled( true ); + EXPECT_EQ( GenericFamily::Serif, SystemFontResolver::genericFamilyFromName( "serif" ) ); + EXPECT_EQ( GenericFamily::SansSerif, + SystemFontResolver::genericFamilyFromName( "sans-serif" ) ); + EXPECT_EQ( GenericFamily::Monospace, SystemFontResolver::genericFamilyFromName( "monospace" ) ); + EXPECT_EQ( GenericFamily::Cursive, SystemFontResolver::genericFamilyFromName( "cursive" ) ); + EXPECT_EQ( GenericFamily::Fantasy, SystemFontResolver::genericFamilyFromName( "fantasy" ) ); + EXPECT_EQ( GenericFamily::SystemUi, SystemFontResolver::genericFamilyFromName( "system-ui" ) ); + EXPECT_EQ( GenericFamily::Emoji, SystemFontResolver::genericFamilyFromName( "emoji" ) ); + EXPECT_EQ( GenericFamily::None, + SystemFontResolver::genericFamilyFromName( "bogus-font-name" ) ); + EXPECT_EQ( GenericFamily::None, SystemFontResolver::genericFamilyFromName( "" ) ); + + EXPECT_EQ( GenericFamily::SansSerif, + SystemFontResolver::genericFamilyFromName( "SANS-SERIF" ) ); + EXPECT_EQ( GenericFamily::Monospace, + SystemFontResolver::genericFamilyFromName( " monospace " ) ); + + SystemFontResolver::destroySingleton(); + SystemFontResolver::setEnabled( false ); +} + +UTEST( SystemFontResolver, enumerate ) { + SystemFontResolver::setEnabled( true ); + auto* resolver = SystemFontResolver::instance(); + const auto& fonts = resolver->enumerate(); + UTEST_PRINTF( "Enumerated %zu system fonts\n", fonts.size() ); + +#if EE_PLATFORM == EE_PLATFORM_LINUX || EE_PLATFORM == EE_PLATFORM_BSD + EXPECT_TRUE_MSG( fonts.size() > 0, "Fontconfig should find fonts on Linux/BSD" ); +#elif EE_PLATFORM == EE_PLATFORM_WIN + EXPECT_TRUE_MSG( fonts.size() > 0, "DirectWrite should find fonts on Windows" ); +#elif EE_PLATFORM == EE_PLATFORM_MACOS || EE_PLATFORM == EE_PLATFORM_IOS + EXPECT_TRUE_MSG( fonts.size() > 0, "CoreText should find fonts on macOS/iOS" ); +#endif + + for ( const auto& desc : fonts ) { + EXPECT_FALSE_MSG( desc.path.empty(), + ( "Font path must not be empty for family: " + desc.family ).c_str() ); + break; + } + + SystemFontResolver::setEnabled( false ); + SystemFontResolver::destroySingleton(); +} + +UTEST( SystemFontResolver, enumerateFamily ) { + SystemFontResolver::setEnabled( true ); + auto* resolver = SystemFontResolver::instance(); + auto fonts = resolver->enumerateFamily( "DejaVu Sans" ); + UTEST_PRINTF( "Found %zu DejaVu Sans fonts\n", fonts.size() ); + + for ( const auto& desc : fonts ) { + EXPECT_FALSE( desc.path.empty() ); + } + + if ( !fonts.empty() ) { + UTEST_PRINT_STEP( "Verify faceIndex is set" ); + EXPECT_EQ( 0, fonts[0].faceIndex ); + } + + SystemFontResolver::setEnabled( false ); + SystemFontResolver::destroySingleton(); +} + +UTEST( SystemFontResolver, findVerdana ) { + SystemFontResolver::setEnabled( true ); + auto* resolver = SystemFontResolver::instance(); + FontQuery query; + query.family = "Verdana"; + query.weight = FontWeight::Normal; + query.italic = false; + FontDesc desc = resolver->resolve( query ); +#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 ) { + if ( !desc.path.empty() ) { + EXPECT_STDSTREQ( "Verdana", desc.family ); + } + } +#endif + SystemFontResolver::setEnabled( false ); + SystemFontResolver::destroySingleton(); +} + +UTEST( SystemFontResolver, resolveFromNamesList ) { + SystemFontResolver::setEnabled( true ); + auto* resolver = SystemFontResolver::instance(); + + UTEST_PRINT_STEP( "Resolve with fallback to sans-serif" ); + FontDesc desc = resolver->resolveFromNamesList( "NonExistentFont12345, sans-serif", + FontWeight::Normal, false ); + +#if EE_PLATFORM == EE_PLATFORM_EMSCRIPTEN + EXPECT_TRUE_MSG( desc.path.empty(), "Emscripten has no system fonts" ); +#else + if ( !desc.path.empty() ) { + EXPECT_FALSE( desc.family.empty() ); + } +#endif + + UTEST_PRINT_STEP( "Resolve empty string" ); + FontDesc descEmpty = resolver->resolveFromNamesList( "", FontWeight::Normal, false ); + EXPECT_TRUE( descEmpty.path.empty() ); + + SystemFontResolver::setEnabled( false ); + SystemFontResolver::destroySingleton(); +} + +UTEST( SystemFontResolver, resolve ) { + SystemFontResolver::setEnabled( true ); + auto* resolver = SystemFontResolver::instance(); + + UTEST_PRINT_STEP( "Resolve a known family" ); + FontQuery query; + query.family = "sans-serif"; + query.weight = FontWeight::Normal; + query.italic = false; + FontDesc desc = resolver->resolve( query ); + + if ( !desc.path.empty() ) { + EXPECT_FALSE( desc.family.empty() ); + } + + UTEST_PRINT_STEP( "Resolve with bogus family" ); + query.family = "zzz_not_a_real_font_family_zzz"; + FontDesc descBogus = resolver->resolve( query ); + EXPECT_TRUE( descBogus.path.empty() ); + + SystemFontResolver::setEnabled( false ); + SystemFontResolver::destroySingleton(); +} + +UTEST( SystemFontResolver, resolveGeneric ) { + SystemFontResolver::setEnabled( true ); + auto* resolver = SystemFontResolver::instance(); + + UTEST_PRINT_STEP( "Resolve monospace" ); + FontDesc descMono = + resolver->resolveGeneric( GenericFamily::Monospace, FontWeight::Normal, false ); + UTEST_PRINTF( "Monospace default: %s at %s\n", descMono.family.c_str(), descMono.path.c_str() ); + + UTEST_PRINT_STEP( "Resolve sans-serif" ); + FontDesc descSans = + resolver->resolveGeneric( GenericFamily::SansSerif, FontWeight::Normal, false ); + + UTEST_PRINT_STEP( "Resolve serif" ); + FontDesc descSerif = + resolver->resolveGeneric( GenericFamily::Serif, FontWeight::Normal, false ); + + UTEST_PRINT_STEP( "Resolve None" ); + FontDesc descNone = resolver->resolveGeneric( GenericFamily::None, FontWeight::Normal, false ); + EXPECT_TRUE( descNone.path.empty() ); + + SystemFontResolver::setEnabled( false ); + SystemFontResolver::destroySingleton(); +} + +UTEST( SystemFontResolver, fontContainsCodepoint ) { + SystemFontResolver::setEnabled( true ); + auto* resolver = SystemFontResolver::instance(); + + std::string fontPath = getFontsDir() + "DejaVuSansMono.ttf"; + ASSERT_TRUE_MSG( FileSystem::fileExists( fontPath ), + ( "Font file not found: " + fontPath ).c_str() ); + + UTEST_PRINT_STEP( "ASCII letter 'A'" ); + EXPECT_TRUE( resolver->fontContainsCodepoint( fontPath, 'A' ) ); + + UTEST_PRINT_STEP( "CJK character U+65E5 (日)" ); + bool hasCJK = resolver->fontContainsCodepoint( fontPath, 0x65E5 ); + UTEST_PRINTF( "DejaVuSansMono has CJK U+65E5: %s\n", hasCJK ? "yes" : "no" ); + + UTEST_PRINT_STEP( "Non-existent file" ); + EXPECT_FALSE( resolver->fontContainsCodepoint( "/nonexistent/font.ttf", 'A' ) ); + + SystemFontResolver::setEnabled( false ); + SystemFontResolver::destroySingleton(); +} + +UTEST( SystemFontResolver, getFallbackForCodepoint ) { + SystemFontResolver::setEnabled( true ); + auto* resolver = SystemFontResolver::instance(); + + UTEST_PRINT_STEP( "Look up fallback for CJK U+65E5 (日)" ); + FontDesc desc = resolver->getFallbackForCodepoint( 0x65E5, FontWeight::Normal, false ); + + if ( !desc.path.empty() ) { + UTEST_PRINTF( "Fallback for U+65E5: %s (%s)\n", desc.family.c_str(), desc.path.c_str() ); + EXPECT_FALSE( desc.family.empty() ); + } + + UTEST_PRINT_STEP( "Look up fallback for Arabic U+0627 (ا)" ); + FontDesc descArabic = resolver->getFallbackForCodepoint( 0x0627, FontWeight::Normal, false ); + if ( !descArabic.path.empty() ) { + UTEST_PRINTF( "Fallback for U+0627: %s (%s)\n", descArabic.family.c_str(), + descArabic.path.c_str() ); + } + + SystemFontResolver::setEnabled( false ); + SystemFontResolver::destroySingleton(); +} + +UTEST( SystemFontResolver, invalidateCache ) { + SystemFontResolver::setEnabled( true ); + auto* resolver = SystemFontResolver::instance(); + + UTEST_PRINT_STEP( "Cache a resolve result" ); + FontQuery query; + query.family = "sans-serif"; + query.weight = FontWeight::Normal; + FontDesc desc1 = resolver->resolve( query ); + + UTEST_PRINT_STEP( "Invalidate cache" ); + resolver->invalidateCache(); + + UTEST_PRINT_STEP( "Re-resolve after invalidation" ); + FontDesc desc2 = resolver->resolve( query ); + + EXPECT_STDSTREQ( desc1.path, desc2.path ); + EXPECT_STDSTREQ( desc1.family, desc2.family ); + + SystemFontResolver::setEnabled( false ); + SystemFontResolver::destroySingleton(); +} + +UTEST( SystemFontResolver, getSystemFont ) { + SystemFontResolver::setEnabled( true ); + auto* resolver = SystemFontResolver::instance(); + + FontDesc sysFont = resolver->getSystemFont(); + FontDesc monoFont = resolver->getSystemMonospaceFont(); + + SystemFontResolver::setEnabled( false ); + SystemFontResolver::destroySingleton(); +} + +UTEST( SystemFontResolver, genericFallbackPurity ) { + SystemFontResolver::setEnabled( true ); + auto* resolver = SystemFontResolver::instance(); + + std::vector scriptSuffixes = { + "georgian", "cjk", "arabic", "hebrew", "armenian", "lao", "thai", + "devanagari", "tamil", "bengali", "gurmukhi", "gujarati", "oriya", + "telugu", "kannada", "malayalam", "sinhala", "khmer", "tibetan", + "myanmar", "ethiopic", "cherokee", "canadian", "mongolian", "yi", + "nko", "tifinagh", "vai", "bamum", "coptic", "glagolitic", "gothic", + "old", "ugaritic", "osmanya", "osmanya", "phags", "syloti" + }; + + auto checkFamily = [&scriptSuffixes]( const FontDesc& desc, const char* genericName ) { + if ( desc.path.empty() ) + return; + std::string lowerFamily = String::toLower( desc.family ); + for ( const char* suffix : scriptSuffixes ) { + std::string suffixStr( suffix ); + if ( lowerFamily.find( suffixStr ) != std::string::npos ) { + UTEST_PRINTF( "WARNING: %s fallback resolved to script-specific font: %s\n", + genericName, desc.family.c_str() ); + } + } + }; + + UTEST_PRINT_STEP( "Check serif generic fallback" ); + FontDesc serif = resolver->resolveGeneric( GenericFamily::Serif, FontWeight::Normal, false ); + checkFamily( serif, "serif" ); + + UTEST_PRINT_STEP( "Check sans-serif generic fallback" ); + FontDesc sans = resolver->resolveGeneric( GenericFamily::SansSerif, FontWeight::Normal, false ); + checkFamily( sans, "sans-serif" ); + + UTEST_PRINT_STEP( "Check monospace generic fallback" ); + FontDesc mono = resolver->resolveGeneric( GenericFamily::Monospace, FontWeight::Normal, false ); + checkFamily( mono, "monospace" ); + + SystemFontResolver::setEnabled( false ); + SystemFontResolver::destroySingleton(); +} + +UTEST( SystemFontResolver, resolveFromNamesListRealWorld ) { + SystemFontResolver::setEnabled( true ); + auto* resolver = SystemFontResolver::instance(); + + UTEST_PRINT_STEP( "Resolve with double-quoted font names" ); + FontDesc desc1 = resolver->resolveFromNamesList( + "\"Helvetica Neue\", Arial, Helvetica, \"Nimbus Sans L\", sans-serif", + FontWeight::Normal, false ); + UTEST_PRINTF( "Resolved: %s (%s)\n", desc1.family.c_str(), desc1.path.c_str() ); + + UTEST_PRINT_STEP( "Resolve with single-quoted font names" ); + FontDesc desc2 = resolver->resolveFromNamesList( + "'Times New Roman', serif", + FontWeight::Normal, false ); + UTEST_PRINTF( "Resolved: %s (%s)\n", desc2.family.c_str(), desc2.path.c_str() ); + + UTEST_PRINT_STEP( "Resolve with mixture of quoted and unquoted" ); + FontDesc desc3 = resolver->resolveFromNamesList( + "Roboto, \"Helvetica Neue\", Arial, sans-serif", + FontWeight::Normal, false ); + UTEST_PRINTF( "Resolved: %s (%s)\n", desc3.family.c_str(), desc3.path.c_str() ); + + UTEST_PRINT_STEP( "Georgia, serif (regression: substring overmatch)" ); + FontDesc desc4 = resolver->resolveFromNamesList( + "Georgia, \"Bitstream Charter\", serif", + FontWeight::Normal, false ); + UTEST_PRINTF( "Resolved: %s (%s)\n", desc4.family.c_str(), desc4.path.c_str() ); + if ( !desc4.path.empty() ) { + std::string lower = String::toLower( desc4.family ); + EXPECT_TRUE_MSG( lower == "georgia" || lower == "times new roman" || + lower.find( "serif" ) != std::string::npos, + ( "Should resolve to Georgia or a serif font, got: " + desc4.family ) + .c_str() ); + } + + SystemFontResolver::setEnabled( false ); + SystemFontResolver::destroySingleton(); +} + +UTEST( SystemFontResolver, resolveGenericWeights ) { + SystemFontResolver::setEnabled( true ); + auto* resolver = SystemFontResolver::instance(); + + UTEST_PRINT_STEP( "Resolve sans-serif at multiple weights" ); + FontDesc normal = resolver->resolveGeneric( GenericFamily::SansSerif, FontWeight::Normal, false ); + FontDesc bold = resolver->resolveGeneric( GenericFamily::SansSerif, FontWeight::Bold, false ); + FontDesc light = resolver->resolveGeneric( GenericFamily::SansSerif, FontWeight::Light, false ); + FontDesc black = resolver->resolveGeneric( GenericFamily::SansSerif, FontWeight::Black, false ); + + UTEST_PRINTF( "Normal: %s\n", normal.family.c_str() ); + UTEST_PRINTF( "Bold: %s\n", bold.family.c_str() ); + UTEST_PRINTF( "Light: %s\n", light.family.c_str() ); + UTEST_PRINTF( "Black: %s\n", black.family.c_str() ); + + if ( !normal.path.empty() ) + EXPECT_FALSE( normal.family.empty() ); + + SystemFontResolver::setEnabled( false ); + SystemFontResolver::destroySingleton(); +} + +UTEST( SystemFontResolver, resolveGenericWeightPreference ) { + SystemFontResolver::setEnabled( true ); + auto* resolver = SystemFontResolver::instance(); + + UTEST_PRINT_STEP( "Normal weight should prefer Normal or close weight" ); + FontDesc normal = resolver->resolveGeneric( GenericFamily::SansSerif, FontWeight::Normal, false ); + if ( !normal.path.empty() ) { + int diff = eeabs( (int)normal.weight - (int)FontWeight::Normal ); + EXPECT_TRUE_MSG( diff <= 300, + ( "Normal weight diff too large: " + normal.family + " weight=" + + String::toString( (int)normal.weight ) ) + .c_str() ); + } + + UTEST_PRINT_STEP( "Bold weight should prefer Bold or close weight" ); + FontDesc bold = resolver->resolveGeneric( GenericFamily::SansSerif, FontWeight::Bold, false ); + if ( !bold.path.empty() ) { + int diff = eeabs( (int)bold.weight - (int)FontWeight::Bold ); + UTEST_PRINTF( "Bold: %s (weight=%d diff=%d)\n", bold.family.c_str(), (int)bold.weight, diff ); + } + + UTEST_PRINT_STEP( "Light weight should prefer Light or close weight" ); + FontDesc light = resolver->resolveGeneric( GenericFamily::SansSerif, FontWeight::Light, false ); + if ( !light.path.empty() ) { + int diff = eeabs( (int)light.weight - (int)FontWeight::Light ); + UTEST_PRINTF( "Light: %s (weight=%d diff=%d)\n", light.family.c_str(), (int)light.weight, diff ); + } + + SystemFontResolver::setEnabled( false ); + SystemFontResolver::destroySingleton(); +} + +UTEST( SystemFontResolver, resolveGenericItalicFallback ) { + SystemFontResolver::setEnabled( true ); + auto* resolver = SystemFontResolver::instance(); + + UTEST_PRINT_STEP( "Request italic — should return a font even if no italic variant" ); + FontDesc italic = resolver->resolveGeneric( GenericFamily::SansSerif, FontWeight::Normal, true ); + if ( !italic.path.empty() ) { + UTEST_PRINTF( "Italic: %s (italic=%d)\n", italic.family.c_str(), (int)italic.italic ); + EXPECT_FALSE( italic.family.empty() ); + } + + SystemFontResolver::setEnabled( false ); + SystemFontResolver::destroySingleton(); +} + +UTEST( SystemFontResolver, resolveBoldFromNamesList ) { + SystemFontResolver::setEnabled( true ); + auto* resolver = SystemFontResolver::instance(); + + UTEST_PRINT_STEP( "Resolve Arial with bold weight" ); + FontDesc desc = resolver->resolveFromNamesList( "Arial, sans-serif", FontWeight::Bold, false ); + UTEST_PRINTF( "Bold: %s (weight=%d)\n", desc.family.c_str(), (int)desc.weight ); + if ( !desc.path.empty() ) { + EXPECT_FALSE( desc.family.empty() ); + } + + SystemFontResolver::setEnabled( false ); + SystemFontResolver::destroySingleton(); +} + +UTEST( SystemFontResolver, invalidateCachePersistence ) { + SystemFontResolver::setEnabled( true ); + auto* resolver = SystemFontResolver::instance(); + + UTEST_PRINT_STEP( "First resolve" ); + FontQuery query; + query.family = "sans-serif"; + query.weight = FontWeight::Normal; + FontDesc first = resolver->resolve( query ); + UTEST_PRINTF( "First: %s (%s)\n", first.family.c_str(), first.path.c_str() ); + + UTEST_PRINT_STEP( "Invalidate cache" ); + resolver->invalidateCache(); + + UTEST_PRINT_STEP( "Second resolve after invalidation" ); + FontDesc second = resolver->resolve( query ); + UTEST_PRINTF( "Second: %s (%s)\n", second.family.c_str(), second.path.c_str() ); + + EXPECT_STDSTREQ( first.path, second.path ); + EXPECT_STDSTREQ( first.family, second.family ); + + SystemFontResolver::setEnabled( false ); + SystemFontResolver::destroySingleton(); +} + +UTEST( SystemFontResolver, glyphFallbackRoundTrip ) { + SystemFontResolver::setEnabled( true ); + auto* resolver = SystemFontResolver::instance(); + + UTEST_PRINT_STEP( "Look up fallback for CJK U+65E5" ); + FontDesc cjk = resolver->getFallbackForCodepoint( 0x65E5, FontWeight::Normal, false ); + if ( !cjk.path.empty() ) { + UTEST_PRINTF( "CJK fallback: %s\n", cjk.family.c_str() ); + EXPECT_TRUE( resolver->fontContainsCodepoint( cjk.path, 0x65E5 ) ); + } + + UTEST_PRINT_STEP( "Look up fallback for Arabic U+0627" ); + FontDesc arabic = resolver->getFallbackForCodepoint( 0x0627, FontWeight::Normal, false ); + if ( !arabic.path.empty() ) { + UTEST_PRINTF( "Arabic fallback: %s\n", arabic.family.c_str() ); + EXPECT_TRUE( resolver->fontContainsCodepoint( arabic.path, 0x0627 ) ); + } + + UTEST_PRINT_STEP( "Verify ASCII fallback" ); + FontDesc ascii = resolver->getFallbackForCodepoint( 'A', FontWeight::Normal, false ); + if ( !ascii.path.empty() ) { + UTEST_PRINTF( "ASCII fallback: %s\n", ascii.family.c_str() ); + EXPECT_TRUE( resolver->fontContainsCodepoint( ascii.path, 'A' ) ); + } + + SystemFontResolver::setEnabled( false ); + SystemFontResolver::destroySingleton(); +} + +UTEST( FontTrueType_faceIndex, loadWithDefaultFaceIndex ) { + FileSystem::changeWorkingDirectory( Sys::getProcessPath() ); + + std::string fontPath = getFontsDir() + "DejaVuSansMono.ttf"; + ASSERT_TRUE( FileSystem::fileExists( fontPath ) ); + + FontTrueType* font = FontTrueType::New( "Test-faceIndex-default" ); + bool loaded = font->loadFromFile( fontPath ); + ASSERT_TRUE( loaded ); + EXPECT_TRUE( font->loaded() ); + + eeDelete( font ); +} + +UTEST( FontTrueType_faceIndex, newWithFaceIndex ) { + FileSystem::changeWorkingDirectory( Sys::getProcessPath() ); + + std::string fontPath = getFontsDir() + "DejaVuSansMono.ttf"; + ASSERT_TRUE( FileSystem::fileExists( fontPath ) ); + + FontTrueType* font = FontTrueType::New( "Test-faceIndex-explicit", fontPath, 0 ); + ASSERT_TRUE( font != nullptr ); + EXPECT_TRUE( font->loaded() ); + + eeDelete( font ); +} + +UTEST( FontTrueType_faceIndex, loadFromMemoryFaceIndex ) { + FileSystem::changeWorkingDirectory( Sys::getProcessPath() ); + + std::string fontPath = getFontsDir() + "DejaVuSansMono.ttf"; + ASSERT_TRUE( FileSystem::fileExists( fontPath ) ); + + ScopedBuffer buf; + FileSystem::fileGet( fontPath, buf ); + + FontTrueType* font = FontTrueType::New( "Test-faceIndex-memory" ); + bool loaded = font->loadFromMemory( buf.get(), buf.length(), true, 0 ); + ASSERT_TRUE( loaded ); + EXPECT_TRUE( font->loaded() ); + + eeDelete( font ); +}