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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,26 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased

### Added

- `MarkupFormat.IsTagged` declares the angle-bracket/entity grammar used for fragment-width
measurement, independently of literal-text encoding. HTML, MXP and Pueblo declare it; custom
formats can opt in explicitly through a new overload without changing existing custom formats.

- `DisplayWidth.Of(body, context)` centralizes picture-placeholder measurement. The new context
property is named `SourceDisplayWidth` to distinguish original run cells from emitted text.

- `DisplayWidth.OfAnsi` and `DisplayWidth.OfTagged` measure text cells in formatted fragments
without counting ANSI sequences, tags or entity syntax. Literal-text width and truncation keep
their existing semantics. MXP, Pueblo and HTML image emitters use tagged measurement when a
caller has not supplied original source-cell geometry.

### Fixed

- Picture placeholder rows preserve their original display-cell width across nested styling and text
encoding. ANSI escapes and HTML/BBCode syntax no longer become extra blank cells beside MXP,
Pueblo, HTML, BBCode or Markdown images. This fixes welcome-screen rules wrapping after a colored logo.

- MXP images a client does not support can fall back to configured ANSI terminal graphics, preserving
the other MXP elements. The fallback requires both enabled graphics and available picture pixels.

Expand Down
6 changes: 4 additions & 2 deletions MarkupString.Ansi/Emitters/ElementEmitters.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ internal sealed class ImageBBCodeEmitter : IMarkupEmitter

public MarkupFormat Format => MarkupFormat.BBCode;

/// <summary>Writes one BBCode image and reserves its source-width cells on each figure row; an unrepresentable address keeps the body.</summary>
public void Emit(IMarkup markup, ReadOnlySpan<char> body, in EmitContext context, IBufferWriter<char> output)
{
ArgumentNullException.ThrowIfNull(output);
Expand All @@ -48,7 +49,7 @@ public void Emit(IMarkup markup, ReadOnlySpan<char> body, in EmitContext context
}

// A figure's rows keep their cells, so what is beside the picture stays where it was.
if (image.Row is not null) output.Write(new string(' ', DisplayWidth.Of(body)));
if (image.Row is not null) output.Write(new string(' ', DisplayWidth.Of(body, context)));
}
}

Expand All @@ -62,6 +63,7 @@ internal sealed class ImageMarkdownEmitter : IMarkupEmitter

public MarkupFormat Format => MarkupFormat.Markdown;

/// <summary>Writes one safe Markdown image and reserves its source-width cells on each figure row; a rejected address keeps the body.</summary>
public void Emit(IMarkup markup, ReadOnlySpan<char> body, in EmitContext context, IBufferWriter<char> output)
{
ArgumentNullException.ThrowIfNull(output);
Expand All @@ -76,6 +78,6 @@ public void Emit(IMarkup markup, ReadOnlySpan<char> body, in EmitContext context
if (image.StartsPicture(context)) MarkdownSyntax.WriteImage(image.Description, source, output);

// A figure's rows keep their cells, so what is beside the picture stays where it was.
if (image.Row is not null) output.Write(new string(' ', DisplayWidth.Of(body)));
if (image.Row is not null) output.Write(new string(' ', DisplayWidth.Of(body, context)));
}
}
3 changes: 2 additions & 1 deletion MarkupString.Html/Emitters/ElementHtmlEmitter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ private enum Shape

public MarkupFormat Format => MarkupFormat.Html;

/// <summary>Writes the shared element under the HTML tag policy, preserving fallback text and the source cells reserved by image rows.</summary>
public void Emit(IMarkup markup, ReadOnlySpan<char> body, in EmitContext context, IBufferWriter<char> output)
{
ArgumentNullException.ThrowIfNull(markup);
Expand Down Expand Up @@ -107,7 +108,7 @@ public void Emit(IMarkup markup, ReadOnlySpan<char> body, in EmitContext context
if (image.StartsPicture(context)) Write(markup, output, "img", Shape.Void, body, context, attributes);

// A figure's rows keep their cells, so what is beside the picture stays where it was.
if (image.Row is not null) output.Write(new string(' ', DisplayWidth.Of(body)));
if (image.Row is not null) output.Write(new string(' ', DisplayWidth.Of(body, context)));
return;
}

Expand Down
3 changes: 2 additions & 1 deletion MarkupString.Mxp/Emitters/ElementMxpEmitter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ internal sealed class ElementMxpEmitter(Type markupType, Func<string, bool>? sup

public MarkupFormat Format => MarkupFormat.Mxp;

/// <summary>Writes supported MXP elements or their fallbacks, preserving the source cells reserved by image rows.</summary>
public void Emit(IMarkup markup, ReadOnlySpan<char> body, in EmitContext context, IBufferWriter<char> output)
{
ArgumentNullException.ThrowIfNull(markup);
Expand Down Expand Up @@ -90,7 +91,7 @@ public void Emit(IMarkup markup, ReadOnlySpan<char> body, in EmitContext context
}

// A figure's rows keep their cells, so what is beside the picture stays where it was.
if (image.Row is not null) output.Write(new string(' ', DisplayWidth.Of(body)));
if (image.Row is not null) output.Write(new string(' ', DisplayWidth.Of(body, context)));
return;
}

Expand Down
3 changes: 2 additions & 1 deletion MarkupString.Pueblo/Emitters/ElementPuebloEmitter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ internal sealed class ElementPuebloEmitter(Type markupType) : IMarkupEmitter

public MarkupFormat Format => MarkupFormat.Pueblo;

/// <summary>Writes Pueblo elements and their fallbacks, preserving the source cells reserved by image rows.</summary>
public void Emit(IMarkup markup, ReadOnlySpan<char> body, in EmitContext context, IBufferWriter<char> output)
{
ArgumentNullException.ThrowIfNull(markup);
Expand Down Expand Up @@ -74,7 +75,7 @@ public void Emit(IMarkup markup, ReadOnlySpan<char> body, in EmitContext context
}

// A figure's rows keep their cells, so what is beside the picture stays where it was.
if (image.Row is not null) output.Write(new string(' ', DisplayWidth.Of(body)));
if (image.Row is not null) output.Write(new string(' ', DisplayWidth.Of(body, context)));
return;

case PreformattedMarkup:
Expand Down
83 changes: 83 additions & 0 deletions MarkupString.Tests/ContextDisplayWidthTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
using System.Buffers;
using MarkupString.Ansi;
using MarkupString.Html;
using MarkupString.Mxp;
using MarkupString.Pueblo;

public class ContextDisplayWidthTests
{
/// <summary>Resolves a built-in format or creates an untagged custom format despite its HTML text encoding.</summary>
private static MarkupFormat Format(string name) => MarkupFormat.TryParse(name)
?? MarkupFormat.Custom(name, TextEncoding.Html);

[Test]
[Arguments("plain")]
[Arguments("ansi")]
[Arguments("html")]
[Arguments("mxp")]
[Arguments("pueblo")]
[Arguments("bbcode")]
[Arguments("markdown")]
[Arguments("custom")]
public async Task SourceGeometryWinsOverTheEmittedBodyInEveryFormat(string name)
{
var context = new EmitContext
{
Format = Format(name), Registry = MarkupRegistry.Empty,
Markups = MarkupSet.Of(new ImageMarkup("cat.png")), SourceDisplayWidth = 3,
};
await Assert.That(DisplayWidth.Of("<GAUGE hp MAX=maxhp>", context)).IsEqualTo(3);
}

[Test]
public async Task ZeroSourceGeometryDoesNotFallBackToVisibleBodyText()
{
var context = new EmitContext
{
Format = MarkupFormat.Mxp, Registry = MarkupRegistry.Empty,
Markups = MarkupSet.Of(new ImageMarkup("cat.png")), SourceDisplayWidth = 0,
};
await Assert.That(DisplayWidth.Of("abc", context)).IsEqualTo(0);
}

[Test]
[Arguments("plain", "<b>&amp;</b>", 12)]
[Arguments("plain", "\e[31mx\e[0m", 8)]
[Arguments("ansi", "\e[31mabc\e[0m", 3)]
[Arguments("ansi", "<b>x</b>", 8)]
[Arguments("html", "\e[31m<b>&amp;界</b>\e[0m", 3)]
[Arguments("mxp", "\e[31m<b>&amp;界</b>\e[0m", 3)]
[Arguments("pueblo", "\e[31m<b>&amp;界</b>\e[0m", 3)]
[Arguments("bbcode", "[b]x[/b]", 8)]
[Arguments("markdown", "**x**", 5)]
[Arguments("custom", "<b>&amp;</b>", 12)]
public async Task MissingSourceGeometryUsesOnlyTheKnownFormatsSyntax(string name, string body, int width)
{
var context = new EmitContext
{
Format = Format(name), Registry = MarkupRegistry.Empty,
Markups = MarkupSet.Of(new ImageMarkup("cat.png")),
};
await Assert.That(DisplayWidth.Of(body, context)).IsEqualTo(width);
}

[Test]
[Arguments("mxp")]
[Arguments("pueblo")]
[Arguments("html")]
[Arguments("bbcode")]
[Arguments("markdown")]
public async Task PictureEmittersReserveTheSuppliedSourceGeometry(string name)
{
var registry = MarkupRegistry.Empty.WithAnsi().WithMxp().WithPueblo().WithHtml();
var format = Format(name);
var image = new ImageMarkup("cat.png") { Row = new PictureRow(1, 2, 3) };
var context = new EmitContext
{
Format = format, Registry = registry, Markups = MarkupSet.Of(image), SourceDisplayWidth = 3,
};
var output = new ArrayBufferWriter<char>();
registry.FindEmitter(typeof(ImageMarkup), format)!.Emit(image, "not the original text", context, output);
await Assert.That(new string(output.WrittenSpan)).IsEqualTo(" ");
}
}
113 changes: 113 additions & 0 deletions MarkupString.Tests/FormattedDisplayWidthTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
using System.Buffers;
using MarkupString.Ansi;
using MarkupString.Html;
using MarkupString.Pueblo;
using MarkupString.Layout;
using MarkupString.Mxp;

public class FormattedDisplayWidthTests
{
[Test]
[Arguments("", 0)]
[Arguments("abc", 3)]
[Arguments("\e[36mabc\e[0m", 3)]
[Arguments("\e[38;2;0;245;183mabc\e[0m", 3)]
[Arguments("\e[?25labc\e[?25h", 3)]
[Arguments("\e]8;;https://example.test\aabc\e]8;;\e\\", 3)]
[Arguments("a\eP1;2qhidden payload\e\\z", 2)]
[Arguments("a\e_hidden payload\e\\z", 2)]
[Arguments("a\e^hidden payload\e\\z", 2)]
[Arguments("a\eXhidden payload\e\\z", 2)]
[Arguments("a\e[", 1)]
[Arguments("a\e]unterminated\nz", 2)]
[Arguments("\u009b31mX", 4)]
[Arguments("\U0001F468\u200D\e[31m\U0001F469\e[0m", 2)]
[Arguments("e\e[31m\u0301界\e[0m", 3)]
[Arguments("<b>x</b>", 8)]
[Arguments("\ud83d\e[31m\udc68", 2)]
public async Task AnsiMeasuresTextWithoutSequenceSyntax(string text, int width)
=> await Assert.That(DisplayWidth.OfAnsi(text)).IsEqualTo(width);

[Test]
[Arguments("", 0)]
[Arguments("abc", 3)]
[Arguments("<b>abc</b>", 3)]
[Arguments("<span title='a > b'>abc</span>", 3)]
[Arguments("&amp;&lt;界", 4)]
[Arguments("&amp;lt;", 4)]
[Arguments("&lt;b&gt;x&lt;/b&gt;", 8)]
[Arguments("a<!-- hidden <b>markup</b> -->z", 2)]
[Arguments("&unknown;", 9)]
[Arguments("<tag", 4)]
[Arguments("&am", 3)]
[Arguments("\e[31m<b>&lt;界</b>\e[0m", 3)]
[Arguments("&#x1f468;&#8205;<b>&#x1f469;</b>", 2)]
[Arguments("e<b>&#769;</b>界", 3)]
[Arguments("a<tag\nz", 6)]
public async Task TaggedMeasuresTextWithoutTagsSequencesOrEntitySyntax(string text, int width)
=> await Assert.That(DisplayWidth.OfTagged(text)).IsEqualTo(width);

[Test]
public async Task PlainWidthStillCountsLiteralMarkup()
{
await Assert.That(DisplayWidth.Of("<b>x</b>")).IsEqualTo(8);
await Assert.That(MarkupText.Plain("&amp;").DisplayWidth).IsEqualTo(5);
await Assert.That(DisplayWidth.Of("[b]x[/b]")).IsEqualTo(8);
await Assert.That(DisplayWidth.IndexAtWidth("<b>x</b>", 3)).IsEqualTo(3);
}

[Test]
public async Task FormattedWidthIgnoresLargeControlStringPayloads()
{
var text = "a\e]0;" + new string('x', 100_000) + "\az";
await Assert.That(DisplayWidth.OfAnsi(text)).IsEqualTo(2);
await Assert.That(DisplayWidth.OfTagged(text)).IsEqualTo(2);
}
[Test]
[Arguments("mxp")]
[Arguments("pueblo")]
[Arguments("html")]
public async Task ImageFallbackMeasuresFormattedTextWithoutSourceMetadata(string name)
{
var registry = MarkupRegistry.Empty.WithAnsi().WithMxp().WithPueblo().WithHtml();
var format = MarkupFormat.TryParse(name)!;
var image = new ImageMarkup("cat.png") { Row = new PictureRow(1, 2, 4) };
var context = new EmitContext { Format = format, Registry = registry, Markups = MarkupSet.Of(image) };
var output = new ArrayBufferWriter<char>();
registry.FindEmitter(typeof(ImageMarkup), format)!.Emit(image, "\e[36m&amp;&lt;界\e[0m", context, output);
await Assert.That(new string(output.WrittenSpan)).IsEqualTo(" ");
}

[Test]
public async Task PictureGeometrySurvivesAnInnerEmitterThatReplacesItsText()
{
var registry = MarkupRegistry.Empty.WithAnsi().WithMxp();
var art = MarkupText.Wrap(new GaugeMarkup("hp", "maxhp"), "abc");
await Assert.That(DisplayWidth.OfTagged(art.Render(MarkupFormat.Mxp, registry))).IsEqualTo(0);
var laid = BlockLayout.Build(new Figure(new ImageMarkup("cat.png"), art), 8);
var output = laid.Render(MarkupFormat.Mxp, registry);
await Assert.That(output.Replace("<IMAGE cat.png W=3c H=1c>", string.Empty)).IsEqualTo(" ");
}

[Test]
public async Task TaggedWidthKeepsOverlongQuotedTagsLiteral()
{
var text = "<b title=\"" + new string('x', 9_000) + "\">z";
await Assert.That(DisplayWidth.OfTagged(text)).IsEqualTo(9_013);
}

[Test]
public async Task TaggedWidthKeepsOverlongEntitiesLiteral()
{
var text = "&" + new string('x', 33) + ";";
await Assert.That(DisplayWidth.OfTagged(text)).IsEqualTo(35);
}

[Test]
public async Task TaggedWidthDoesNotCountLargeComments()
{
var text = "a<!--" + new string('x', 100_000) + "-->z";
await Assert.That(DisplayWidth.OfTagged(text)).IsEqualTo(2);
}

}
65 changes: 65 additions & 0 deletions MarkupString.Tests/Layout/FigurePictureTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,71 @@ public async Task MxpWritesTheFiguresPictureOnceAndKeepsItsCells()
await Assert.That(lines.Length).IsEqualTo(3);
}

/// <summary>Verifies that styling syntax never enlarges the picture placeholder or moves its neighboring text.</summary>
[Test]
[Arguments("mxp", AnsiColorDepth.Standard, true)]
[Arguments("mxp", AnsiColorDepth.TrueColor, true)]
[Arguments("mxp", AnsiColorDepth.None, false)]
[Arguments("pueblo", AnsiColorDepth.Standard, true)]
[Arguments("pueblo", AnsiColorDepth.TrueColor, true)]
[Arguments("html", AnsiColorDepth.TrueColor, true)]
[Arguments("bbcode", AnsiColorDepth.TrueColor, true)]
[Arguments("markdown", AnsiColorDepth.TrueColor, true)]
public async Task StyledPictureRowsKeepTheirOriginalCells(string format, AnsiColorDepth depth, bool styled)
{
var art = MarkupText.Plain(new string('=', 33));
if (styled) art = MarkupText.Wrap(new AnsiMarkup(new AnsiStyle { Foreground = new AnsiColor.Rgb(0, 245, 183) }), art);
var text = MarkupText.Concat(MarkupText.Wrap(Cat with { Row = new PictureRow(0, 1, 33) }, art), MarkupText.Plain(" END"));
var output = text.Render(format switch { "mxp" => MarkupFormat.Mxp, "pueblo" => MarkupFormat.Pueblo, "html" => MarkupFormat.Html, "bbcode" => MarkupFormat.BBCode, _ => MarkupFormat.Markdown }, Registry.WithAnsiOutput(depth));
var lines = output.Split('\n');
foreach (var line in lines)
{
var plain = System.Text.RegularExpressions.Regex.Replace(line, "<[^>]*>|\\[img\\].*?\\[/img\\]|!\\[[^]]*\\]\\([^)]*\\)", string.Empty);
await Assert.That(plain).IsEqualTo(new string(' ', 35) + "END");
}
}

/// <summary>Verifies source-cell padding across differently styled runs containing entities, CJK text and combining marks.</summary>
[Test]
[Arguments("mxp")]
[Arguments("pueblo")]
[Arguments("html")]
[Arguments("bbcode")]
[Arguments("markdown")]
public async Task SplitPictureRowsMeasureSourceCellsNotEncodedEntities(string format)
{
var colored = MarkupText.Wrap(new AnsiMarkup(new AnsiStyle { Foreground = new AnsiColor.Rgb(0, 245, 183) }), "&<界");
var red = MarkupText.Wrap(new AnsiMarkup(new AnsiStyle { Foreground = new AnsiColor.Rgb(255, 0, 0) }), "e\u0301");
var art = MarkupText.Concat(colored, red);
var text = MarkupText.Concat(MarkupText.Wrap(Cat with { Row = new PictureRow(0, 1, 5) }, art), MarkupText.Plain(" END"));
var output = text.Render(format switch { "mxp" => MarkupFormat.Mxp, "pueblo" => MarkupFormat.Pueblo, "html" => MarkupFormat.Html, "bbcode" => MarkupFormat.BBCode, _ => MarkupFormat.Markdown }, Registry.WithAnsiOutput(AnsiColorDepth.TrueColor));
var plain = System.Text.RegularExpressions.Regex.Replace(output, "<[^>]*>|\\[img\\].*?\\[/img\\]|!\\[[^]]*\\]\\([^)]*\\)", string.Empty);
await Assert.That(plain).IsEqualTo(new string(' ', 7) + "END");
}

/// <summary>Reproduces the two reported banner widths and verifies that every rule stays within its negotiated row.</summary>
[Test]
[Arguments(117, AnsiColorDepth.Standard)]
[Arguments(134, AnsiColorDepth.TrueColor)]
public async Task StyledLogoKeepsWelcomeRulesInsideNegotiatedWidth(int width, AnsiColorDepth depth)
{
var art = MarkupText.Wrap(new AnsiMarkup(new AnsiStyle { Foreground = new AnsiColor.Rgb(0, 245, 183) }),
MarkupText.Plain(string.Join('\n', Enumerable.Repeat(new string('=', 33), 18))));
var block = new Flex([
new Sized(new Figure(Cat, art)) { Basis = BlockSize.Cells(33), Grow = 0 },
new Stack([.. Enumerable.Repeat<Block>(new Rule(MarkupText.Plain("New here?")) { TitleAlignment = Alignment.Left }, 18)])
]);
var lines = BlockLayout.Build(block, width).Render(MarkupFormat.Mxp, Registry.WithAnsiOutput(depth)).Split('\n');
await Assert.That(lines.Length).IsEqualTo(18);
foreach (var line in lines)
{
var plain = System.Text.RegularExpressions.Regex.Replace(line, "<[^>]*>", string.Empty);
await Assert.That(DisplayWidth.Of(plain)).IsEqualTo(width);
await Assert.That(plain[..35]).IsEqualTo(new string(' ', 35));
await Assert.That(plain[35]).IsNotEqualTo(' ');
}
}

[Test]
public async Task AFigureWithNoArtIsItsPictureNotItsDescription()
{
Expand Down
Loading
Loading