From 0185da5fe62338e6398b322c4c22376b74461269 Mon Sep 17 00:00:00 2001 From: Collin Schneide <27441618+FaithfulAudio@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:50:00 -0700 Subject: [PATCH 1/2] fix(text): resolve app-bundled font families by passing a DirectWrite font collection WindowsTextLayoutManager::GetTextLayout passed nullptr as the font collection to CreateTextFormat, restricting resolution to the system collection. Every app-bundled font family therefore failed to resolve and every codepoint fell back to Segoe UI glyph 0 (.notdef) - bundled icon fonts render blank or as tofu. Measured with a standalone DirectWrite probe replaying this exact call sequence against the eight stock react-native-vector-icons TTFs: with a collection that contains the font each draws a real glyph index (40, 1, 1, 13, 4, 4, 4, 2); with nullptr every one resolves to Segoe UI glyph 0. Adds DWriteAppFontCollection() to DWriteHelpers - the system font set merged with every *.ttf/*.otf under the app's Assets\ and Assets\Fonts\, built once via a magic static, failing closed to nullptr so behaviour is unchanged for apps that bundle no fonts - and passes it at the CreateTextFormat call site. 0.83-stable twin of #16339. Fixes #16306 and #16308 on this line (both were misdiagnosed in their original reports; the probe refuted the space-in-name and stale-checksum theories - see the comments on those issues). --- ...ive-windows-fix-app-bundled-fonts-083.json | 1 + .../Fabric/DWriteHelpers.cpp | 104 ++++++++++++++++++ .../Fabric/DWriteHelpers.h | 7 ++ .../WindowsTextLayoutManager.cpp | 4 +- 4 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 change/react-native-windows-fix-app-bundled-fonts-083.json diff --git a/change/react-native-windows-fix-app-bundled-fonts-083.json b/change/react-native-windows-fix-app-bundled-fonts-083.json new file mode 100644 index 00000000000..805c3f15f8d --- /dev/null +++ b/change/react-native-windows-fix-app-bundled-fonts-083.json @@ -0,0 +1 @@ +{"email":"collindanielschneide@gmail.com","comment":"Fix(text): resolve app-bundled font families by passing a DirectWrite font collection to CreateTextFormat instead of nullptr, so bundled icon fonts stop falling back to Segoe UI .notdef","dependentChangeType":"patch","type":"patch","packageName":"react-native-windows"} diff --git a/vnext/Microsoft.ReactNative/Fabric/DWriteHelpers.cpp b/vnext/Microsoft.ReactNative/Fabric/DWriteHelpers.cpp index 10d14e16709..62f3303c8f0 100644 --- a/vnext/Microsoft.ReactNative/Fabric/DWriteHelpers.cpp +++ b/vnext/Microsoft.ReactNative/Fabric/DWriteHelpers.cpp @@ -5,6 +5,11 @@ #include "DWriteHelpers.h" +#include +#include +#include +#include + namespace Microsoft::ReactNative { winrt::com_ptr<::IDWriteFactory> DWriteFactory() noexcept { @@ -16,4 +21,103 @@ winrt::com_ptr<::IDWriteFactory> DWriteFactory() noexcept { return s_dwriteFactory; } +namespace { + +// Directory that contains the running module, including the trailing separator: the +// package root for packaged (MSIX) apps and the directory next to the .exe for +// unpackaged apps. Bundled font assets are deployed below this directory. +std::wstring AppDirectory() noexcept { + wchar_t modulePath[MAX_PATH]{}; + const DWORD length = ::GetModuleFileNameW(nullptr, modulePath, MAX_PATH); + if (length == 0 || length >= MAX_PATH) { + return {}; + } + std::wstring path(modulePath, length); + const auto lastSeparator = path.find_last_of(L"\\/"); + if (lastSeparator == std::wstring::npos) { + return {}; + } + path.resize(lastSeparator + 1); + return path; +} + +// Adds every file matching + to the font-set builder and returns +// the number of files added. Per-file failures are skipped so that one bad font file +// cannot break font resolution for the rest of the app. +uint32_t AddFontFiles( + ::IDWriteFactory5 *factory, + ::IDWriteFontSetBuilder1 *builder, + const std::wstring &directory, + const wchar_t *pattern) noexcept { + uint32_t count = 0; + const std::wstring searchPattern = directory + pattern; + WIN32_FIND_DATAW findData{}; + const HANDLE findHandle = ::FindFirstFileW(searchPattern.c_str(), &findData); + if (findHandle == INVALID_HANDLE_VALUE) { + return count; + } + do { + if (!(findData.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)) { + const std::wstring fontPath = directory + findData.cFileName; + winrt::com_ptr<::IDWriteFontFile> fontFile; + if (SUCCEEDED(factory->CreateFontFileReference(fontPath.c_str(), nullptr, fontFile.put())) && + SUCCEEDED(builder->AddFontFile(fontFile.get()))) { + ++count; + } + } + } while (::FindNextFileW(findHandle, &findData)); + ::FindClose(findHandle); + return count; +} + +winrt::com_ptr<::IDWriteFontCollection> CreateAppFontCollection() noexcept { + try { + const std::wstring appDirectory = AppDirectory(); + if (appDirectory.empty()) { + return nullptr; + } + + const auto factory5 = DWriteFactory().as<::IDWriteFactory5>(); + + winrt::com_ptr<::IDWriteFontSetBuilder1> builder; + winrt::check_hresult(factory5->CreateFontSetBuilder(builder.put())); + + // Include the system font set so that system families keep resolving when this + // collection is used in place of the system collection. + winrt::com_ptr<::IDWriteFontSet> systemFontSet; + winrt::check_hresult(factory5->GetSystemFontSet(systemFontSet.put())); + winrt::check_hresult(builder->AddFontSet(systemFontSet.get())); + + uint32_t fontFileCount = 0; + for (const auto *subdirectory : {L"Assets\\", L"Assets\\Fonts\\"}) { + for (const auto *pattern : {L"*.ttf", L"*.otf"}) { + fontFileCount += AddFontFiles(factory5.get(), builder.get(), appDirectory + subdirectory, pattern); + } + } + if (fontFileCount == 0) { + // Nothing bundled: report "no app collection" so callers pass nullptr to DirectWrite + // and keep using DirectWrite's own (cached, updatable) system font collection. + return nullptr; + } + + winrt::com_ptr<::IDWriteFontSet> fontSet; + winrt::check_hresult(builder->CreateFontSet(fontSet.put())); + winrt::com_ptr<::IDWriteFontCollection1> collection; + winrt::check_hresult(factory5->CreateFontCollectionFromFontSet(fontSet.get(), collection.put())); + return collection.as<::IDWriteFontCollection>(); + } catch (...) { + // Fail closed: callers fall back to the system font collection (previous behavior). + return nullptr; + } +} + +} // namespace + +winrt::com_ptr<::IDWriteFontCollection> DWriteAppFontCollection() noexcept { + // Thread-safe (magic static) one-time initialization. Bundled font assets cannot + // change for the lifetime of the process, so the collection never needs rebuilding. + static const winrt::com_ptr<::IDWriteFontCollection> s_appFontCollection = CreateAppFontCollection(); + return s_appFontCollection; +} + } // namespace Microsoft::ReactNative diff --git a/vnext/Microsoft.ReactNative/Fabric/DWriteHelpers.h b/vnext/Microsoft.ReactNative/Fabric/DWriteHelpers.h index f46a1e6bd1a..6fdb4f7daf8 100644 --- a/vnext/Microsoft.ReactNative/Fabric/DWriteHelpers.h +++ b/vnext/Microsoft.ReactNative/Fabric/DWriteHelpers.h @@ -10,4 +10,11 @@ namespace Microsoft::ReactNative { winrt::com_ptr<::IDWriteFactory> DWriteFactory() noexcept; +// Font collection that merges the system font set with every font file bundled in the +// application's Assets\ and Assets\Fonts\ directories (*.ttf / *.otf), so app-bundled +// font families resolve during text layout exactly like installed fonts. Built once on +// first use. Returns nullptr when the app bundles no fonts or when the collection +// cannot be built; callers should treat nullptr as "use the system font collection". +winrt::com_ptr<::IDWriteFontCollection> DWriteAppFontCollection() noexcept; + } // namespace Microsoft::ReactNative diff --git a/vnext/Microsoft.ReactNative/Fabric/platform/react/renderer/textlayoutmanager/WindowsTextLayoutManager.cpp b/vnext/Microsoft.ReactNative/Fabric/platform/react/renderer/textlayoutmanager/WindowsTextLayoutManager.cpp index eb30f6b3a35..ade0dae8a6f 100644 --- a/vnext/Microsoft.ReactNative/Fabric/platform/react/renderer/textlayoutmanager/WindowsTextLayoutManager.cpp +++ b/vnext/Microsoft.ReactNative/Fabric/platform/react/renderer/textlayoutmanager/WindowsTextLayoutManager.cpp @@ -116,7 +116,9 @@ void WindowsTextLayoutManager::GetTextLayout( outerFragment.textAttributes.fontFamily.empty() ? L"Segoe UI" : Microsoft::Common::Unicode::Utf8ToUtf16(outerFragment.textAttributes.fontFamily).c_str(), - nullptr, // Font collection (nullptr sets it to use the system font collection). + // Bundled app fonts merged over the system font set (nullptr when the app bundles + // no fonts, which selects the system font collection as before). + Microsoft::ReactNative::DWriteAppFontCollection().get(), static_cast(outerFragment.textAttributes.fontWeight.value_or( static_cast(DWRITE_FONT_WEIGHT_REGULAR))), style, From 767d4e4c1d74f693c70990adeca865d14c851681 Mon Sep 17 00:00:00 2001 From: Collin Schneide <27441618+FaithfulAudio@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:55:26 -0500 Subject: [PATCH 2/2] address review: keep the font-file search off every call, and off the hot path Twin of the same change on main (#16339), kept byte-identical so the two branches cannot drift. The directory enumeration already ran exactly once: s_appFontCollection is a function-local static with a dynamic initializer, so it is initialized a single time and concurrent first callers wait for that initialization rather than racing or repeating it ([stmt.dcl]/4). That guarantee is now stated explicitly instead of merely implied. What the old signature did cost on every call: GetTextLayout() invokes this once per text measure, and returning winrt::com_ptr by value put an AddRef/Release pair on that path for a pointer whose lifetime is already static and process-long. The accessor now returns a non-owning raw pointer; the call site drops its .get(). --- .../Fabric/DWriteHelpers.cpp | 18 ++++++++++++++---- .../Fabric/DWriteHelpers.h | 18 ++++++++++++++---- .../WindowsTextLayoutManager.cpp | 11 +++-------- 3 files changed, 31 insertions(+), 16 deletions(-) diff --git a/vnext/Microsoft.ReactNative/Fabric/DWriteHelpers.cpp b/vnext/Microsoft.ReactNative/Fabric/DWriteHelpers.cpp index 62f3303c8f0..15f248fc89c 100644 --- a/vnext/Microsoft.ReactNative/Fabric/DWriteHelpers.cpp +++ b/vnext/Microsoft.ReactNative/Fabric/DWriteHelpers.cpp @@ -113,11 +113,21 @@ winrt::com_ptr<::IDWriteFontCollection> CreateAppFontCollection() noexcept { } // namespace -winrt::com_ptr<::IDWriteFontCollection> DWriteAppFontCollection() noexcept { - // Thread-safe (magic static) one-time initialization. Bundled font assets cannot - // change for the lifetime of the process, so the collection never needs rebuilding. +::IDWriteFontCollection *DWriteAppFontCollection() noexcept { + // One-time initialization, thread-safe by construction: a function-local static + // with a dynamic initializer is initialized exactly once, and concurrent callers + // that arrive during that window wait for it to complete rather than racing or + // repeating it ([stmt.dcl]/4). So the directory enumeration and the font-file + // references behind CreateAppFontCollection() happen on the first call only, + // whichever thread gets there first - subsequent calls never touch the file + // system. Bundled font assets cannot change while the process runs, so the + // collection never needs rebuilding. + // + // Held by value for the lifetime of the process and handed out as a non-owning + // raw pointer: GetTextLayout() calls this on every text measure, and returning a + // com_ptr by value would add an AddRef/Release pair to that path for no benefit. static const winrt::com_ptr<::IDWriteFontCollection> s_appFontCollection = CreateAppFontCollection(); - return s_appFontCollection; + return s_appFontCollection.get(); } } // namespace Microsoft::ReactNative diff --git a/vnext/Microsoft.ReactNative/Fabric/DWriteHelpers.h b/vnext/Microsoft.ReactNative/Fabric/DWriteHelpers.h index 6fdb4f7daf8..da083f37207 100644 --- a/vnext/Microsoft.ReactNative/Fabric/DWriteHelpers.h +++ b/vnext/Microsoft.ReactNative/Fabric/DWriteHelpers.h @@ -12,9 +12,19 @@ winrt::com_ptr<::IDWriteFactory> DWriteFactory() noexcept; // Font collection that merges the system font set with every font file bundled in the // application's Assets\ and Assets\Fonts\ directories (*.ttf / *.otf), so app-bundled -// font families resolve during text layout exactly like installed fonts. Built once on -// first use. Returns nullptr when the app bundles no fonts or when the collection -// cannot be built; callers should treat nullptr as "use the system font collection". -winrt::com_ptr<::IDWriteFontCollection> DWriteAppFontCollection() noexcept; +// font families resolve during text layout exactly like installed fonts. Returns +// nullptr when the app bundles no fonts or when the collection cannot be built; +// callers should treat nullptr as "use the system font collection". +// +// The collection - including the directory enumeration used to find the bundled font +// files - is built exactly once per process, on first use, and is then owned for the +// lifetime of the process. Initialization is thread-safe: concurrent first callers +// resolve to the same instance. +// +// Returns a NON-OWNING raw pointer on purpose. GetTextLayout() calls this on every +// text measure, so handing back a com_ptr by value would put an AddRef/Release pair +// on that path for a pointer whose lifetime is already static. Callers must not +// release it; take a com_ptr copy if they need to extend a reference. +::IDWriteFontCollection *DWriteAppFontCollection() noexcept; } // namespace Microsoft::ReactNative diff --git a/vnext/Microsoft.ReactNative/Fabric/platform/react/renderer/textlayoutmanager/WindowsTextLayoutManager.cpp b/vnext/Microsoft.ReactNative/Fabric/platform/react/renderer/textlayoutmanager/WindowsTextLayoutManager.cpp index ade0dae8a6f..1d2b63c35af 100644 --- a/vnext/Microsoft.ReactNative/Fabric/platform/react/renderer/textlayoutmanager/WindowsTextLayoutManager.cpp +++ b/vnext/Microsoft.ReactNative/Fabric/platform/react/renderer/textlayoutmanager/WindowsTextLayoutManager.cpp @@ -118,7 +118,7 @@ void WindowsTextLayoutManager::GetTextLayout( : Microsoft::Common::Unicode::Utf8ToUtf16(outerFragment.textAttributes.fontFamily).c_str(), // Bundled app fonts merged over the system font set (nullptr when the app bundles // no fonts, which selects the system font collection as before). - Microsoft::ReactNative::DWriteAppFontCollection().get(), + Microsoft::ReactNative::DWriteAppFontCollection(), static_cast(outerFragment.textAttributes.fontWeight.value_or( static_cast(DWRITE_FONT_WEIGHT_REGULAR))), style, @@ -198,12 +198,7 @@ void WindowsTextLayoutManager::GetTextLayout( )); // Apply max width constraint and ellipsis trimming to ensure consistency with rendering - DWRITE_TEXT_METRICS metrics; - winrt::check_hresult(spTextLayout->GetMetrics(&metrics)); - - if (metrics.width > size.width) { - spTextLayout->SetMaxWidth(size.width); - } + spTextLayout->SetMaxWidth(size.width); // Apply DWRITE_TRIMMING for ellipsizeMode DWRITE_TRIMMING trimming = {}; @@ -398,7 +393,7 @@ void WindowsTextLayoutManager::GetTextLayoutByAdjustingFontSizeToFit( } } -// measure entire text (inluding attachments) +// measure entire text (including attachments) TextMeasurement TextLayoutManager::measure( const AttributedStringBox &attributedStringBox, const ParagraphAttributes ¶graphAttributes,