diff --git a/src/Forma.DynamicText/DynamicTextCoverage.cs b/src/Forma.DynamicText/DynamicTextCoverage.cs
new file mode 100644
index 00000000..b750d278
--- /dev/null
+++ b/src/Forma.DynamicText/DynamicTextCoverage.cs
@@ -0,0 +1,69 @@
+// Copyright (c) 2026 Igor Hipólito Vieira
+// SPDX-License-Identifier: MIT
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Text;
+
+namespace Forma
+{
+ /// A character that no face of a font stack covers, with the first string that contains it.
+ public sealed class MissingGlyph
+ {
+ /// Creates a missing-glyph finding.
+ public MissingGlyph(int codePoint, string text)
+ {
+ CodePoint = codePoint;
+ Text = text;
+ }
+
+ /// The Unicode code point no face covers.
+ public int CodePoint { get; }
+ /// The first string that contains the character.
+ public string Text { get; }
+ /// The character as a string.
+ public string Character => char.ConvertFromUtf32(CodePoint);
+ /// The code point, the character and its string.
+ public override string ToString() => $"U+{CodePoint:X4} '{Character}' in \"{Text}\"";
+ }
+
+ /// Finds the characters of a set of strings that a font stack cannot draw, so a missing glyph is caught before a release.
+ public static class DynamicTextCoverage
+ {
+ /// The characters of that neither 's face nor any of its fallback faces cover.
+ public static IReadOnlyList FindMissingGlyphs(DynamicUIFont font, IEnumerable strings)
+ {
+ if (font == null) throw new ArgumentNullException(nameof(font));
+ var faces = new List { font.Face };
+ faces.AddRange(font.FallbackFaces);
+ return FindMissingGlyphs(faces, strings);
+ }
+
+ /// The characters of that none of covers. Control and formatting characters are ignored.
+ public static IReadOnlyList FindMissingGlyphs(IEnumerable faces, IEnumerable strings)
+ {
+ if (faces == null) throw new ArgumentNullException(nameof(faces));
+ if (strings == null) throw new ArgumentNullException(nameof(strings));
+ var stack = new List(faces);
+ var missing = new List();
+ var seen = new HashSet();
+ foreach (var text in strings)
+ {
+ if (string.IsNullOrEmpty(text)) continue;
+ foreach (var rune in text.EnumerateRunes())
+ {
+ var category = Rune.GetUnicodeCategory(rune);
+ if (category is UnicodeCategory.Control or UnicodeCategory.Format or UnicodeCategory.OtherNotAssigned) continue;
+ if (!seen.Add(rune.Value)) continue;
+ var covered = false;
+ foreach (var face in stack)
+ if (face.SupportsCharacter(rune.Value)) { covered = true; break; }
+ if (!covered) missing.Add(new MissingGlyph(rune.Value, text));
+ }
+ }
+
+ return missing;
+ }
+ }
+}
diff --git a/tests/Forma.Tests/DynamicTextCoverageTest.cs b/tests/Forma.Tests/DynamicTextCoverageTest.cs
new file mode 100644
index 00000000..93d57088
--- /dev/null
+++ b/tests/Forma.Tests/DynamicTextCoverageTest.cs
@@ -0,0 +1,73 @@
+// Copyright (c) 2026 Igor Hipólito Vieira
+// SPDX-License-Identifier: MIT
+
+using System.Linq;
+
+
+namespace Forma.Tests
+{
+ [TestFixture]
+ public sealed class DynamicTextCoverageTest
+ {
+ private static UIFontFace Inter() => UIFontFace.FromProjectFile(TestContext.CurrentContext.TestDirectory, "Fonts/Inter_Regular.ttf");
+
+ private static UIFontFace Cjk() => UIFontFace.FromProjectFile(TestContext.CurrentContext.TestDirectory, "Fonts/NotoSansCJK_Subset.ttf");
+
+ private static int KanjiOnlyIn(UIFontFace cjk, UIFontFace latin) =>
+ cjk.GetSupportedCodePoints().First(codePoint => codePoint >= 0x4E00 && !latin.SupportsCharacter(codePoint));
+
+ [Test]
+ public void ACoveredString_HasNoMissingGlyphs()
+ {
+ using var face = Inter();
+
+ Assert.That(DynamicTextCoverage.FindMissingGlyphs(new[] { face }, new[] { "Save and Exit", "Açúcar é doce" }), Is.Empty);
+ }
+
+ [Test]
+ public void AUncoveredCharacter_IsReportedWithItsString_OncePerCodePoint()
+ {
+ using var face = Inter();
+ using var cjk = Cjk();
+ var kanji = char.ConvertFromUtf32(KanjiOnlyIn(cjk, face));
+
+ var missing = DynamicTextCoverage.FindMissingGlyphs(new[] { face }, new[] { "Map " + kanji, kanji + kanji });
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(face.SupportsCharacter(char.ConvertToUtf32(kanji, 0)), Is.False);
+ Assert.That(missing, Has.Count.EqualTo(1));
+ Assert.That(missing[0].CodePoint, Is.EqualTo(char.ConvertToUtf32(kanji, 0)));
+ Assert.That(missing[0].Text, Is.EqualTo("Map " + kanji));
+ Assert.That(missing[0].ToString(), Does.Contain("U+" + missing[0].CodePoint.ToString("X4")));
+ });
+ }
+
+ [Test]
+ public void AFallbackFace_RestoresCoverage()
+ {
+ using var inter = Inter();
+ using var noto = Cjk();
+ var text = "Map " + char.ConvertFromUtf32(KanjiOnlyIn(noto, inter));
+
+ var withoutFallback = DynamicTextCoverage.FindMissingGlyphs(new[] { inter }, new[] { text });
+ var withFallback = DynamicTextCoverage.FindMissingGlyphs(new[] { inter, noto }, new[] { text });
+ var font = new DynamicUIFont(inter, 16, UIFontHinting.Default, System.Array.Empty(), noto);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(withoutFallback, Is.Not.Empty);
+ Assert.That(withFallback, Is.Empty);
+ Assert.That(DynamicTextCoverage.FindMissingGlyphs(font, new[] { text }), Is.Empty);
+ });
+ }
+
+ [Test]
+ public void ControlAndFormatCharacters_AreIgnored()
+ {
+ using var face = Inter();
+
+ Assert.That(DynamicTextCoverage.FindMissingGlyphs(new[] { face }, new[] { "a\tb\nc\u200Bd\u202Be" }), Is.Empty);
+ }
+ }
+}