Skip to content

Commit 3a33e36

Browse files
Fold text for clients without Unicode (AsciiFold) (#59)
AsciiFold replaces each character an ASCII-only or Latin-1 client cannot show with the nearest one it can, keeping markup and, for the built-in stand-ins, each character's width. A game's own stand-ins come first. LayoutContext.Fold folds the text inside every block before it is measured, through Block.MapText, so layouts stay aligned. Claude-Session: https://claude.ai/code/session_01Wkz2sT8dyvFw7JXXNXvjYN Co-authored-by: Claude <noreply@anthropic.com>
1 parent c51d0c0 commit 3a33e36

19 files changed

Lines changed: 571 additions & 5 deletions

‎CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
88

99
## Unreleased
1010

11+
### Added
12+
13+
- **Text for a client without Unicode.** `AsciiFold` replaces each character an ASCII-only or
14+
Latin-1 client cannot show with the nearest one it can (`·` to `*`, `—` to `-`, `é` to `e`, `?` for
15+
the rest), keeping markup and, for the built-in stand-ins, each character's width. A game's own
16+
stand-ins come first. `LayoutContext.Fold` folds the text inside every block before it is
17+
measured, through the new `Block.MapText`, so tables stay aligned whatever the stand-ins' widths.
18+
1119
## 2.16.0 — 2026-10-09
1220

1321
### Added
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
using MarkupString.Ansi;
2+
using MarkupString.Layout;
3+
4+
namespace MarkupString.Tests;
5+
6+
public class AsciiFoldTests
7+
{
8+
private static readonly AnsiMarkup Red = AnsiMarkup.Create(foreground: new AnsiColor.Rgb(255, 0, 0));
9+
10+
[Test]
11+
[Arguments("Scene 3 · 21:04", "Scene 3 * 21:04")]
12+
[Arguments("one — two – three", "one - two - three")]
13+
[Arguments("“quoted” ‘single’", "\"quoted\" 'single'")]
14+
[Arguments("café naïve Ångström", "cafe naive Angstrom")]
15+
[Arguments("café", "cafe")]
16+
[Arguments("a b", "a b")]
17+
[Arguments("→ ← ×", "> < x")]
18+
[Arguments("Straße Øre", "Strase Ore")]
19+
[Arguments("┌─┐ ═ █", "+-+ = #")]
20+
[Arguments("plain ascii\ttab\n", "plain ascii\ttab\n")]
21+
public async Task Fold_ReplacesWithTheNearestAscii(string text, string expected)
22+
=> await Assert.That(AsciiFold.Default.Fold(text)).IsEqualTo(expected);
23+
24+
[Test]
25+
public async Task Fold_KeepsEachCharactersWidth()
26+
{
27+
// A wide character is two question marks, a fullwidth letter the letter and a space.
28+
await Assert.That(AsciiFold.Default.Fold("日本")).IsEqualTo("????");
29+
await Assert.That(AsciiFold.Default.Fold("😀!")).IsEqualTo("??!");
30+
await Assert.That(AsciiFold.Default.Fold("AB")).IsEqualTo("A B ");
31+
await Assert.That(AsciiFold.Default.Fold("👩‍👩‍👧")).IsEqualTo("??");
32+
}
33+
34+
[Test]
35+
public async Task Fold_ForLatin1_KeepsWhatLatin1Has()
36+
{
37+
var fold = new AsciiFold(latin1: true);
38+
await Assert.That(fold.Fold("· café — ½")).IsEqualTo("· café - ½");
39+
await Assert.That(fold.Fold("café")).IsEqualTo("café");
40+
}
41+
42+
[Test]
43+
public async Task Translations_ComeBeforeTheBuiltInOnes()
44+
{
45+
var fold = new AsciiFold([new("·", " - "), new("…", "..."), new("❤️", "<3")]);
46+
await Assert.That(fold.Fold("a·b…")).IsEqualTo("a - b...");
47+
await Assert.That(fold.Fold("I ❤️ it")).IsEqualTo("I <3 it");
48+
await Assert.That(new AsciiFold([new("·", "+")], latin1: true).Fold("a·b")).IsEqualTo("a+b");
49+
}
50+
51+
[Test]
52+
[Arguments("ab", "x")]
53+
[Arguments("a", "x")]
54+
[Arguments("", "x")]
55+
[Arguments("·", "é")]
56+
[Arguments("·", "\n")]
57+
public async Task Translations_RejectAKeyOrValueThatCannotWork(string key, string value)
58+
=> await Assert.That(() => new AsciiFold([new(key, value)])).Throws<ArgumentException>();
59+
60+
[Test]
61+
public async Task Fold_KeepsMarkupOnTheCharacterItReplaces()
62+
{
63+
var text = MarkupText.Concat([MarkupText.Plain("a"), MarkupText.Wrap(Red, "·…"), MarkupText.Plain("b")]);
64+
var folded = new AsciiFold([new("…", "...")]).Fold(text);
65+
66+
await Assert.That(folded.Text).IsEqualTo("a*...b");
67+
await Assert.That(folded.Runs.Length).IsEqualTo(1);
68+
await Assert.That(folded.Runs[0]).IsEqualTo(new Run(1, 4, MarkupSet.Of(Red)));
69+
}
70+
71+
[Test]
72+
public async Task Fold_LeavesAsciiTextAsItIs()
73+
{
74+
var text = MarkupText.Wrap(Red, "nothing to do");
75+
await Assert.That(AsciiFold.Default.Fold(text)).IsSameReferenceAs(text);
76+
}
77+
78+
[Test]
79+
public async Task Fold_KeepsAPointsCarrier()
80+
{
81+
var text = MarkupText.Concat([MarkupText.Plain("·"), MarkupText.Sound("ping.wav")]);
82+
var folded = AsciiFold.Default.Fold(text);
83+
84+
await Assert.That(folded.Text).IsEqualTo("*" + MarkupText.PointCarrier);
85+
await Assert.That(folded.Runs.Single().Start).IsEqualTo(1);
86+
}
87+
88+
[Test]
89+
public async Task Layout_MeasuresTheFoldedText()
90+
{
91+
var table = new Table([new TableColumn(MarkupText.Plain("Scene")), new TableColumn(MarkupText.Plain("When"))],
92+
[
93+
[MarkupText.Plain("Tea · Garden"), MarkupText.Plain("21:04")],
94+
[MarkupText.Plain("Duel…"), MarkupText.Plain("now")],
95+
]);
96+
var block = new Stack([new Rule(MarkupText.Plain("Wren · Scenes")), table]);
97+
var context = new LayoutContext { AsciiOnly = true, Fold = new AsciiFold([new("·", " - "), new("…", "...")]) };
98+
99+
var lines = BlockLayout.Lines(block, 30, context).Select(line => line.ToPlainText()).ToArray();
100+
101+
await Assert.That(lines[0]).Contains("Wren - Scenes");
102+
await Assert.That(lines.Any(line => line.Contains("Tea - Garden"))).IsTrue();
103+
await Assert.That(lines.Any(line => line.Contains("Duel..."))).IsTrue();
104+
await Assert.That(lines.All(line => System.Text.Ascii.IsValid(line))).IsTrue();
105+
// The column after the folded text still starts at one place on every row.
106+
var when = lines.Where(line => line.Contains("21:04") || line.Contains("now")).Select(line => line.IndexOf(line.Contains("21:04") ? "21:04" : "now")).Distinct();
107+
await Assert.That(when.Count()).IsEqualTo(1);
108+
}
109+
110+
[Test]
111+
public async Task Relayout_KeepsTheTreeAsGiven()
112+
{
113+
var built = BlockLayout.Build(new Rule(MarkupText.Plain("a · b")), 20, fluid: true);
114+
var relaid = BlockLayout.Relayout(built, 20, new LayoutContext { AsciiOnly = true, Fold = AsciiFold.Default });
115+
116+
await Assert.That(relaid.ToPlainText()).Contains("a * b");
117+
var layout = relaid.Runs.SelectMany(run => run.Markups).OfType<LayoutMarkup>().Single();
118+
await Assert.That(((Rule)layout.Root).Title!.ToPlainText()).IsEqualTo("a · b");
119+
}
120+
}

0 commit comments

Comments
 (0)