Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions src/Forma.DynamicText/DynamicTextCoverage.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>A character that no face of a font stack covers, with the first string that contains it.</summary>
public sealed class MissingGlyph
{
/// <summary>Creates a missing-glyph finding.</summary>
public MissingGlyph(int codePoint, string text)
{
CodePoint = codePoint;
Text = text;
}

/// <summary>The Unicode code point no face covers.</summary>
public int CodePoint { get; }
/// <summary>The first string that contains the character.</summary>
public string Text { get; }
/// <summary>The character as a string.</summary>
public string Character => char.ConvertFromUtf32(CodePoint);
/// <summary>The code point, the character and its string.</summary>
public override string ToString() => $"U+{CodePoint:X4} '{Character}' in \"{Text}\"";
}

/// <summary>Finds the characters of a set of strings that a font stack cannot draw, so a missing glyph is caught before a release.</summary>
public static class DynamicTextCoverage
{
/// <summary>The characters of <paramref name="strings"/> that neither <paramref name="font"/>'s face nor any of its fallback faces cover.</summary>
public static IReadOnlyList<MissingGlyph> FindMissingGlyphs(DynamicUIFont font, IEnumerable<string> strings)
{
if (font == null) throw new ArgumentNullException(nameof(font));
var faces = new List<UIFontFace> { font.Face };
faces.AddRange(font.FallbackFaces);
return FindMissingGlyphs(faces, strings);
}

/// <summary>The characters of <paramref name="strings"/> that none of <paramref name="faces"/> covers. Control and formatting characters are ignored.</summary>
public static IReadOnlyList<MissingGlyph> FindMissingGlyphs(IEnumerable<UIFontFace> faces, IEnumerable<string> strings)
{
if (faces == null) throw new ArgumentNullException(nameof(faces));
if (strings == null) throw new ArgumentNullException(nameof(strings));
var stack = new List<UIFontFace>(faces);
var missing = new List<MissingGlyph>();
var seen = new HashSet<int>();
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;
}
}
}
73 changes: 73 additions & 0 deletions tests/Forma.Tests/DynamicTextCoverageTest.cs
Original file line number Diff line number Diff line change
@@ -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<UIFontVariationCoordinate>(), 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);
}
}
}
Loading