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); + } + } +}