From 9dc537b5a5ffb850ead87b61da8c40712b33db77 Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Sun, 8 Nov 2026 23:56:22 -0600 Subject: [PATCH 1/5] Measure picture placeholders before nested styling and encoding --- CHANGELOG.md | 4 ++ MarkupString.Ansi/Emitters/ElementEmitters.cs | 4 +- .../Emitters/ElementHtmlEmitter.cs | 2 +- .../Emitters/ElementMxpEmitter.cs | 2 +- .../Emitters/ElementPuebloEmitter.cs | 2 +- .../Layout/FigurePictureTests.cs | 62 +++++++++++++++++++ MarkupString/EmitContext.cs | 6 ++ MarkupString/MarkupTextRenderer.cs | 1 + MarkupString/PublicAPI.Unshipped.txt | 3 + 9 files changed, 81 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dba2c52..85c3a42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### 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. diff --git a/MarkupString.Ansi/Emitters/ElementEmitters.cs b/MarkupString.Ansi/Emitters/ElementEmitters.cs index bb80773..18cce37 100644 --- a/MarkupString.Ansi/Emitters/ElementEmitters.cs +++ b/MarkupString.Ansi/Emitters/ElementEmitters.cs @@ -48,7 +48,7 @@ public void Emit(IMarkup markup, ReadOnlySpan 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(' ', context.BodyDisplayWidth ?? DisplayWidth.Of(body))); } } @@ -76,6 +76,6 @@ public void Emit(IMarkup markup, ReadOnlySpan 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(' ', context.BodyDisplayWidth ?? DisplayWidth.Of(body))); } } diff --git a/MarkupString.Html/Emitters/ElementHtmlEmitter.cs b/MarkupString.Html/Emitters/ElementHtmlEmitter.cs index 4868009..49fcdef 100644 --- a/MarkupString.Html/Emitters/ElementHtmlEmitter.cs +++ b/MarkupString.Html/Emitters/ElementHtmlEmitter.cs @@ -107,7 +107,7 @@ public void Emit(IMarkup markup, ReadOnlySpan 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(' ', context.BodyDisplayWidth ?? DisplayWidth.Of(body))); return; } diff --git a/MarkupString.Mxp/Emitters/ElementMxpEmitter.cs b/MarkupString.Mxp/Emitters/ElementMxpEmitter.cs index 788c7b5..e69d739 100644 --- a/MarkupString.Mxp/Emitters/ElementMxpEmitter.cs +++ b/MarkupString.Mxp/Emitters/ElementMxpEmitter.cs @@ -90,7 +90,7 @@ public void Emit(IMarkup markup, ReadOnlySpan 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(' ', context.BodyDisplayWidth ?? DisplayWidth.Of(body))); return; } diff --git a/MarkupString.Pueblo/Emitters/ElementPuebloEmitter.cs b/MarkupString.Pueblo/Emitters/ElementPuebloEmitter.cs index 21e5436..12de200 100644 --- a/MarkupString.Pueblo/Emitters/ElementPuebloEmitter.cs +++ b/MarkupString.Pueblo/Emitters/ElementPuebloEmitter.cs @@ -74,7 +74,7 @@ public void Emit(IMarkup markup, ReadOnlySpan 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(' ', context.BodyDisplayWidth ?? DisplayWidth.Of(body))); return; case PreformattedMarkup: diff --git a/MarkupString.Tests/Layout/FigurePictureTests.cs b/MarkupString.Tests/Layout/FigurePictureTests.cs index ed04ae7..d9bf810 100644 --- a/MarkupString.Tests/Layout/FigurePictureTests.cs +++ b/MarkupString.Tests/Layout/FigurePictureTests.cs @@ -43,6 +43,68 @@ public async Task MxpWritesTheFiguresPictureOnceAndKeepsItsCells() await Assert.That(lines.Length).IsEqualTo(3); } + [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"); + } + } + + [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"); + } + + [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(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() { diff --git a/MarkupString/EmitContext.cs b/MarkupString/EmitContext.cs index 64e4c41..ebd31a0 100644 --- a/MarkupString/EmitContext.cs +++ b/MarkupString/EmitContext.cs @@ -17,6 +17,12 @@ public readonly ref struct EmitContext /// This run's own markups, innermost first. public required MarkupSet Markups { get; init; } + /// + /// Display cells in the original run, before text encoding or nested emitters add control sequences + /// and tags. Point carriers occupy zero cells. Null when a caller constructed this context without it. + /// + public int? BodyDisplayWidth { get; init; } + /// The previous run's markups, or when a gap or the start of the text precedes this run. public MarkupSet? Previous { get; init; } diff --git a/MarkupString/MarkupTextRenderer.cs b/MarkupString/MarkupTextRenderer.cs index f12908a..db26d41 100644 --- a/MarkupString/MarkupTextRenderer.cs +++ b/MarkupString/MarkupTextRenderer.cs @@ -322,6 +322,7 @@ private static bool RenderBody(MarkupText text, MarkupFormat format, MarkupRegis Format = format, Registry = registry, Markups = run.Markups, + BodyDisplayWidth = CountPoints(run.Markups) > 0 ? 0 : DisplayWidth.Of(content.Slice(run.Start, run.Length)), Previous = i > 0 && runs[i - 1].End == run.Start ? runs[i - 1].Markups : null, Next = i + 1 < runs.Length && runs[i + 1].Start == run.End ? runs[i + 1].Markups : null, IsFirstRun = i == 0, diff --git a/MarkupString/PublicAPI.Unshipped.txt b/MarkupString/PublicAPI.Unshipped.txt index c23c282..9b94be8 100644 --- a/MarkupString/PublicAPI.Unshipped.txt +++ b/MarkupString/PublicAPI.Unshipped.txt @@ -826,3 +826,6 @@ MarkupString.Decoding.ISequenceObserver MarkupString.Decoding.ISequenceObserver.SequenceSeen(MarkupString.Decoding.DecodeContext! context) -> void MarkupString.Decoding.DecodeContext.PendingLine.get -> MarkupString.MarkupText! MarkupString.Decoding.DecodeContext.Insert(int column, MarkupString.MarkupText! text) -> void + +MarkupString.EmitContext.BodyDisplayWidth.get -> int? +MarkupString.EmitContext.BodyDisplayWidth.init -> void From 6fb57ebfb660fd3bc22e376f744c110fb96ec45c Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Mon, 9 Nov 2026 00:57:14 -0600 Subject: [PATCH 2/5] Measure ANSI and tagged fragments without formatting syntax --- CHANGELOG.md | 7 ++ .../Emitters/ElementHtmlEmitter.cs | 2 +- .../Emitters/ElementMxpEmitter.cs | 2 +- .../Emitters/ElementPuebloEmitter.cs | 2 +- .../FormattedDisplayWidthTests.cs | 113 ++++++++++++++++++ MarkupString/DisplayWidth.Formatted.cs | 89 ++++++++++++++ MarkupString/DisplayWidth.cs | 5 +- MarkupString/EmitContext.cs | 4 +- MarkupString/PublicAPI.Unshipped.txt | 2 + README.md | 6 + 10 files changed, 226 insertions(+), 6 deletions(-) create mode 100644 MarkupString.Tests/FormattedDisplayWidthTests.cs create mode 100644 MarkupString/DisplayWidth.Formatted.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 85c3a42..b1f53a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased +### Added + +- `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 diff --git a/MarkupString.Html/Emitters/ElementHtmlEmitter.cs b/MarkupString.Html/Emitters/ElementHtmlEmitter.cs index 49fcdef..ec83d1a 100644 --- a/MarkupString.Html/Emitters/ElementHtmlEmitter.cs +++ b/MarkupString.Html/Emitters/ElementHtmlEmitter.cs @@ -107,7 +107,7 @@ public void Emit(IMarkup markup, ReadOnlySpan 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(' ', context.BodyDisplayWidth ?? DisplayWidth.Of(body))); + if (image.Row is not null) output.Write(new string(' ', context.BodyDisplayWidth ?? DisplayWidth.OfTagged(body))); return; } diff --git a/MarkupString.Mxp/Emitters/ElementMxpEmitter.cs b/MarkupString.Mxp/Emitters/ElementMxpEmitter.cs index e69d739..fc4299b 100644 --- a/MarkupString.Mxp/Emitters/ElementMxpEmitter.cs +++ b/MarkupString.Mxp/Emitters/ElementMxpEmitter.cs @@ -90,7 +90,7 @@ public void Emit(IMarkup markup, ReadOnlySpan 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(' ', context.BodyDisplayWidth ?? DisplayWidth.Of(body))); + if (image.Row is not null) output.Write(new string(' ', context.BodyDisplayWidth ?? DisplayWidth.OfTagged(body))); return; } diff --git a/MarkupString.Pueblo/Emitters/ElementPuebloEmitter.cs b/MarkupString.Pueblo/Emitters/ElementPuebloEmitter.cs index 12de200..2e077f6 100644 --- a/MarkupString.Pueblo/Emitters/ElementPuebloEmitter.cs +++ b/MarkupString.Pueblo/Emitters/ElementPuebloEmitter.cs @@ -74,7 +74,7 @@ public void Emit(IMarkup markup, ReadOnlySpan 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(' ', context.BodyDisplayWidth ?? DisplayWidth.Of(body))); + if (image.Row is not null) output.Write(new string(' ', context.BodyDisplayWidth ?? DisplayWidth.OfTagged(body))); return; case PreformattedMarkup: diff --git a/MarkupString.Tests/FormattedDisplayWidthTests.cs b/MarkupString.Tests/FormattedDisplayWidthTests.cs new file mode 100644 index 0000000..1378812 --- /dev/null +++ b/MarkupString.Tests/FormattedDisplayWidthTests.cs @@ -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("x", 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("abc", 3)] + [Arguments("abc", 3)] + [Arguments("&<界", 4)] + [Arguments("&lt;", 4)] + [Arguments("<b>x</b>", 8)] + [Arguments("az", 2)] + [Arguments("&unknown;", 9)] + [Arguments("<界\e[0m", 3)] + [Arguments("👨‍👩", 2)] + [Arguments("é界", 3)] + [Arguments("a await Assert.That(DisplayWidth.OfTagged(text)).IsEqualTo(width); + + [Test] + public async Task PlainWidthStillCountsLiteralMarkup() + { + await Assert.That(DisplayWidth.Of("x")).IsEqualTo(8); + await Assert.That(MarkupText.Plain("&").DisplayWidth).IsEqualTo(5); + await Assert.That(DisplayWidth.Of("[b]x[/b]")).IsEqualTo(8); + await Assert.That(DisplayWidth.IndexAtWidth("x", 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(); + registry.FindEmitter(typeof(ImageMarkup), format)!.Emit(image, "\e[36m&<界\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("", string.Empty)).IsEqualTo(" "); + } + + [Test] + public async Task TaggedWidthKeepsOverlongQuotedTagsLiteral() + { + var text = "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 = "az"; + await Assert.That(DisplayWidth.OfTagged(text)).IsEqualTo(2); + } + +} diff --git a/MarkupString/DisplayWidth.Formatted.cs b/MarkupString/DisplayWidth.Formatted.cs new file mode 100644 index 0000000..0482a91 --- /dev/null +++ b/MarkupString/DisplayWidth.Formatted.cs @@ -0,0 +1,89 @@ +using System.Buffers; +using MarkupString.Decoding; + +namespace MarkupString; + +public static partial class DisplayWidth +{ + // Width needs the sequence boundaries, never a picture, title or other payload. Keep the + // shared tokenizer's consumption rules without retaining arbitrarily large strings. + private static readonly VtLimits WidthLimits = new() { MaxStringLength = 0, MaxApcLength = 0 }; + + /// + /// Cells occupied by the text in an ANSI fragment, excluding complete or incomplete terminal + /// escape sequences. Unlike , this reads formatting syntax. + /// + /// + /// Uses 's UTF-8 rules: C1 characters are not eight-bit sequence introducers. + /// Counts text, not cursor movement, tab stops, screen erasure or image dimensions. Line breaks + /// occupy zero cells; widths across lines are added, not measured as a bounding rectangle. + /// + public static int OfAnsi(ReadOnlySpan text) => + text.Contains('\e') ? OfFormatted(text, tagged: false) : Of(text); + + /// + /// Cells occupied by the text in an HTML, MXP or Pueblo fragment: tags, comments and ANSI + /// sequences occupy no cells, and entities are decoded once before measuring their text. + /// + /// + /// Uses and : unknown entities and incomplete + /// tags remain literal text, as do tags or entities longer than the scanner limits. This is a + /// text measure, not browser layout or protocol execution: + /// it does not evaluate CSS, scripts, custom MXP entities, image dimensions or cursor movement. + /// Line breaks occupy zero cells and widths across lines are added. Use + /// for literal source text; this method does not read Markdown or BBCode. + /// + public static int OfTagged(ReadOnlySpan text) => + text.IndexOfAny('<', '&', '\e') < 0 ? Of(text) : OfFormatted(text, tagged: true); + + private static int OfFormatted(ReadOnlySpan text, bool tagged) + { + using var plain = new PooledCharWriter(Math.Min(text.Length, 256)); + var sink = new WidthSink(plain, tagged); + new VtTokenizer(WidthLimits).Feed(text, sink); + sink.Finish(); + // Measure the resulting text together: formatting may divide a surrogate pair or a + // joined emoji into separate callbacks, without adding a visible boundary between them. + return Of(plain.WrittenSpan); + } + + private sealed class WidthSink(PooledCharWriter plain, bool tagged) : IVtTokenSink, ITagSink + { + private readonly TagScanner? _tags = tagged ? new TagScanner() : null; + public bool ReadsMarkup => true; + + public void Print(ReadOnlySpan text) + { + if (_tags is null) plain.Write(text); + else _tags.Feed(text, this); + } + + public void Execute(char control) + { + Span text = stackalloc char[1]; + text[0] = control; + Print(text); + } + + public void Text(ReadOnlySpan text) => plain.Write(text); + public void Tag(ReadOnlySpan body) { } + public void Entity(ReadOnlySpan name) + { + if (TagSyntax.Entity(name) is { } decoded) plain.Write(decoded); + else + { + plain.Write("&"); + plain.Write(name); + plain.Write(";"); + } + } + + public void Csi(in CsiSequence sequence) { } + public void Esc(in EscSequence sequence) { } + public void Osc(ReadOnlySpan payload) { } + public void Dcs(in CsiSequence header, ReadOnlySpan data) { } + public void Apc(ReadOnlySpan payload) { } + public void Abandoned(VtSequenceKind kind) { } + public void Finish() => _tags?.Flush(this); + } +} diff --git a/MarkupString/DisplayWidth.cs b/MarkupString/DisplayWidth.cs index 201ddc9..bed8127 100644 --- a/MarkupString/DisplayWidth.cs +++ b/MarkupString/DisplayWidth.cs @@ -16,11 +16,12 @@ public enum ControlCharacterWidth } /// -/// Terminal column width of text: 0 cells for controls, combining marks and format characters, +/// Terminal column width of literal, unencoded text: 0 cells for controls, combining marks and format characters, /// 2 for East Asian wide and fullwidth code points, 1 for everything else. Each measure has an /// overload taking a that counts controls as 1 instead. +/// Use or for text that already contains formatting syntax. /// -public static class DisplayWidth +public static partial class DisplayWidth { private const int ZeroWidthJoiner = 0x200D; private const int HangulJungseongStart = 0x1160; diff --git a/MarkupString/EmitContext.cs b/MarkupString/EmitContext.cs index ebd31a0..9d19a57 100644 --- a/MarkupString/EmitContext.cs +++ b/MarkupString/EmitContext.cs @@ -19,7 +19,9 @@ public readonly ref struct EmitContext /// /// Display cells in the original run, before text encoding or nested emitters add control sequences - /// and tags. Point carriers occupy zero cells. Null when a caller constructed this context without it. + /// and tags. An inner emitter may replace its text entirely, so measuring the emitted fragment + /// cannot always recover this geometry. Point carriers occupy zero cells. Null when a caller + /// constructed this context without it. /// public int? BodyDisplayWidth { get; init; } diff --git a/MarkupString/PublicAPI.Unshipped.txt b/MarkupString/PublicAPI.Unshipped.txt index 9b94be8..08e5e9c 100644 --- a/MarkupString/PublicAPI.Unshipped.txt +++ b/MarkupString/PublicAPI.Unshipped.txt @@ -829,3 +829,5 @@ MarkupString.Decoding.DecodeContext.Insert(int column, MarkupString.MarkupText! MarkupString.EmitContext.BodyDisplayWidth.get -> int? MarkupString.EmitContext.BodyDisplayWidth.init -> void +static MarkupString.DisplayWidth.OfAnsi(System.ReadOnlySpan text) -> int +static MarkupString.DisplayWidth.OfTagged(System.ReadOnlySpan text) -> int diff --git a/README.md b/README.md index eb8f9bf..f7e302c 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,12 @@ MarkupString keeps three measurements separate because they answer different que | Grapheme clusters | User-perceived characters that must stay intact | `GraphemeCount`, `EnumerateGraphemes` | | Display cells | Columns occupied in a terminal | `DisplayWidth`, `TruncateToWidth` | +`DisplayWidth.Of(text)` measures literal source text. For an already-rendered fragment, use +`DisplayWidth.OfAnsi(text)` for ANSI sequences or `DisplayWidth.OfTagged(text)` for HTML, MXP, +and Pueblo tags, entities, and ANSI. These measure the fragment's text cells, not browser layout, +image dimensions, or terminal cursor motion. They do not parse Markdown or BBCode. For example, +`Of("&")` is 5, while `OfTagged("&")` is 1. + That distinction powers grapheme-safe wrapping, truncation, padding, alignment, and multi-column layouts: From 2b79ac575857341435417edcaecd560a596d8f8e Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Mon, 9 Nov 2026 01:16:31 -0600 Subject: [PATCH 3/5] Centralize source-aware display width resolution --- CHANGELOG.md | 3 + MarkupString.Ansi/Emitters/ElementEmitters.cs | 4 +- .../Emitters/ElementHtmlEmitter.cs | 2 +- .../Emitters/ElementMxpEmitter.cs | 2 +- .../Emitters/ElementPuebloEmitter.cs | 2 +- .../ContextDisplayWidthTests.cs | 82 +++++++++++++++++++ MarkupString/DisplayWidth.Formatted.cs | 20 +++++ MarkupString/EmitContext.cs | 2 +- MarkupString/MarkupTextRenderer.cs | 2 +- MarkupString/PublicAPI.Unshipped.txt | 5 +- README.md | 5 ++ 11 files changed, 120 insertions(+), 9 deletions(-) create mode 100644 MarkupString.Tests/ContextDisplayWidthTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index b1f53a7..d2214ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added +- `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 diff --git a/MarkupString.Ansi/Emitters/ElementEmitters.cs b/MarkupString.Ansi/Emitters/ElementEmitters.cs index 18cce37..f58b1c1 100644 --- a/MarkupString.Ansi/Emitters/ElementEmitters.cs +++ b/MarkupString.Ansi/Emitters/ElementEmitters.cs @@ -48,7 +48,7 @@ public void Emit(IMarkup markup, ReadOnlySpan 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(' ', context.BodyDisplayWidth ?? DisplayWidth.Of(body))); + if (image.Row is not null) output.Write(new string(' ', DisplayWidth.Of(body, context))); } } @@ -76,6 +76,6 @@ public void Emit(IMarkup markup, ReadOnlySpan 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(' ', context.BodyDisplayWidth ?? DisplayWidth.Of(body))); + if (image.Row is not null) output.Write(new string(' ', DisplayWidth.Of(body, context))); } } diff --git a/MarkupString.Html/Emitters/ElementHtmlEmitter.cs b/MarkupString.Html/Emitters/ElementHtmlEmitter.cs index ec83d1a..b9a20ef 100644 --- a/MarkupString.Html/Emitters/ElementHtmlEmitter.cs +++ b/MarkupString.Html/Emitters/ElementHtmlEmitter.cs @@ -107,7 +107,7 @@ public void Emit(IMarkup markup, ReadOnlySpan 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(' ', context.BodyDisplayWidth ?? DisplayWidth.OfTagged(body))); + if (image.Row is not null) output.Write(new string(' ', DisplayWidth.Of(body, context))); return; } diff --git a/MarkupString.Mxp/Emitters/ElementMxpEmitter.cs b/MarkupString.Mxp/Emitters/ElementMxpEmitter.cs index fc4299b..3295c53 100644 --- a/MarkupString.Mxp/Emitters/ElementMxpEmitter.cs +++ b/MarkupString.Mxp/Emitters/ElementMxpEmitter.cs @@ -90,7 +90,7 @@ public void Emit(IMarkup markup, ReadOnlySpan 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(' ', context.BodyDisplayWidth ?? DisplayWidth.OfTagged(body))); + if (image.Row is not null) output.Write(new string(' ', DisplayWidth.Of(body, context))); return; } diff --git a/MarkupString.Pueblo/Emitters/ElementPuebloEmitter.cs b/MarkupString.Pueblo/Emitters/ElementPuebloEmitter.cs index 2e077f6..9f81117 100644 --- a/MarkupString.Pueblo/Emitters/ElementPuebloEmitter.cs +++ b/MarkupString.Pueblo/Emitters/ElementPuebloEmitter.cs @@ -74,7 +74,7 @@ public void Emit(IMarkup markup, ReadOnlySpan 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(' ', context.BodyDisplayWidth ?? DisplayWidth.OfTagged(body))); + if (image.Row is not null) output.Write(new string(' ', DisplayWidth.Of(body, context))); return; case PreformattedMarkup: diff --git a/MarkupString.Tests/ContextDisplayWidthTests.cs b/MarkupString.Tests/ContextDisplayWidthTests.cs new file mode 100644 index 0000000..91b9fab --- /dev/null +++ b/MarkupString.Tests/ContextDisplayWidthTests.cs @@ -0,0 +1,82 @@ +using System.Buffers; +using MarkupString.Ansi; +using MarkupString.Html; +using MarkupString.Mxp; +using MarkupString.Pueblo; + +public class ContextDisplayWidthTests +{ + 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("", 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", "&", 12)] + [Arguments("plain", "\e[31mx\e[0m", 8)] + [Arguments("ansi", "\e[31mabc\e[0m", 3)] + [Arguments("ansi", "x", 8)] + [Arguments("html", "\e[31m&界\e[0m", 3)] + [Arguments("mxp", "\e[31m&界\e[0m", 3)] + [Arguments("pueblo", "\e[31m&界\e[0m", 3)] + [Arguments("bbcode", "[b]x[/b]", 8)] + [Arguments("markdown", "**x**", 5)] + [Arguments("custom", "&", 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(); + registry.FindEmitter(typeof(ImageMarkup), format)!.Emit(image, "not the original text", context, output); + await Assert.That(new string(output.WrittenSpan)).IsEqualTo(" "); + } +} diff --git a/MarkupString/DisplayWidth.Formatted.cs b/MarkupString/DisplayWidth.Formatted.cs index 0482a91..d96ecf5 100644 --- a/MarkupString/DisplayWidth.Formatted.cs +++ b/MarkupString/DisplayWidth.Formatted.cs @@ -36,6 +36,26 @@ public static int OfAnsi(ReadOnlySpan text) => public static int OfTagged(ReadOnlySpan text) => text.IndexOfAny('<', '&', '\e') < 0 ? Of(text) : OfFormatted(text, tagged: true); + /// + /// Cells reserved by the run's original source when + /// is supplied; otherwise measures using the context's known format. + /// + /// + /// Source geometry wins even when an inner emitter replaced the text, and zero is a supplied width. + /// Without it, ANSI uses and HTML, MXP and Pueblo use . + /// Plain text, Markdown, BBCode and custom formats retain literal + /// measurement; there is no Markdown, BBCode or custom-format parser here. A manually constructed + /// context should supply source geometry when its body cannot be measured by those fallbacks. + /// + public static int Of(ReadOnlySpan body, in EmitContext context) + { + if (context.SourceDisplayWidth is { } width) return width; + if (context.Format == MarkupFormat.Ansi) return OfAnsi(body); + if (context.Format == MarkupFormat.Html || context.Format == MarkupFormat.Mxp || context.Format == MarkupFormat.Pueblo) + return OfTagged(body); + return Of(body); + } + private static int OfFormatted(ReadOnlySpan text, bool tagged) { using var plain = new PooledCharWriter(Math.Min(text.Length, 256)); diff --git a/MarkupString/EmitContext.cs b/MarkupString/EmitContext.cs index 9d19a57..970e7d3 100644 --- a/MarkupString/EmitContext.cs +++ b/MarkupString/EmitContext.cs @@ -23,7 +23,7 @@ public readonly ref struct EmitContext /// cannot always recover this geometry. Point carriers occupy zero cells. Null when a caller /// constructed this context without it. /// - public int? BodyDisplayWidth { get; init; } + public int? SourceDisplayWidth { get; init; } /// The previous run's markups, or when a gap or the start of the text precedes this run. public MarkupSet? Previous { get; init; } diff --git a/MarkupString/MarkupTextRenderer.cs b/MarkupString/MarkupTextRenderer.cs index db26d41..561a3e5 100644 --- a/MarkupString/MarkupTextRenderer.cs +++ b/MarkupString/MarkupTextRenderer.cs @@ -322,7 +322,7 @@ private static bool RenderBody(MarkupText text, MarkupFormat format, MarkupRegis Format = format, Registry = registry, Markups = run.Markups, - BodyDisplayWidth = CountPoints(run.Markups) > 0 ? 0 : DisplayWidth.Of(content.Slice(run.Start, run.Length)), + SourceDisplayWidth = CountPoints(run.Markups) > 0 ? 0 : DisplayWidth.Of(content.Slice(run.Start, run.Length)), Previous = i > 0 && runs[i - 1].End == run.Start ? runs[i - 1].Markups : null, Next = i + 1 < runs.Length && runs[i + 1].Start == run.End ? runs[i + 1].Markups : null, IsFirstRun = i == 0, diff --git a/MarkupString/PublicAPI.Unshipped.txt b/MarkupString/PublicAPI.Unshipped.txt index 08e5e9c..c6666e6 100644 --- a/MarkupString/PublicAPI.Unshipped.txt +++ b/MarkupString/PublicAPI.Unshipped.txt @@ -827,7 +827,8 @@ MarkupString.Decoding.ISequenceObserver.SequenceSeen(MarkupString.Decoding.Decod MarkupString.Decoding.DecodeContext.PendingLine.get -> MarkupString.MarkupText! MarkupString.Decoding.DecodeContext.Insert(int column, MarkupString.MarkupText! text) -> void -MarkupString.EmitContext.BodyDisplayWidth.get -> int? -MarkupString.EmitContext.BodyDisplayWidth.init -> void +MarkupString.EmitContext.SourceDisplayWidth.get -> int? +MarkupString.EmitContext.SourceDisplayWidth.init -> void static MarkupString.DisplayWidth.OfAnsi(System.ReadOnlySpan text) -> int static MarkupString.DisplayWidth.OfTagged(System.ReadOnlySpan text) -> int +static MarkupString.DisplayWidth.Of(System.ReadOnlySpan body, in MarkupString.EmitContext context) -> int diff --git a/README.md b/README.md index f7e302c..38535c0 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,11 @@ and Pueblo tags, entities, and ANSI. These measure the fragment's text cells, no image dimensions, or terminal cursor motion. They do not parse Markdown or BBCode. For example, `Of("&")` is 5, while `OfTagged("&")` is 1. +Inside an emitter, `DisplayWidth.Of(body, context)` uses `context.SourceDisplayWidth` when available, +so a picture keeps its reserved source cells even if an inner emitter replaced the text. Without +source geometry it selects the ANSI or tagged helper for known formats; Plain, Markdown, BBCode, +and custom formats keep literal measurement. The renderer supplies source geometry for every run. + That distinction powers grapheme-safe wrapping, truncation, padding, alignment, and multi-column layouts: From 13d16aa328fb54d18a305ec17275f5867eee3331 Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Mon, 9 Nov 2026 01:43:11 -0600 Subject: [PATCH 4/5] Declare tagged fragment syntax on markup formats --- CHANGELOG.md | 4 ++ MarkupString.Tests/TaggedFormatTests.cs | 78 +++++++++++++++++++++++++ MarkupString/DisplayWidth.Formatted.cs | 13 ++--- MarkupString/MarkupFormat.cs | 39 ++++++++++--- MarkupString/PublicAPI.Unshipped.txt | 2 + README.md | 8 ++- 6 files changed, 126 insertions(+), 18 deletions(-) create mode 100644 MarkupString.Tests/TaggedFormatTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index d2214ae..6fecba1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### 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. diff --git a/MarkupString.Tests/TaggedFormatTests.cs b/MarkupString.Tests/TaggedFormatTests.cs new file mode 100644 index 0000000..ce72ead --- /dev/null +++ b/MarkupString.Tests/TaggedFormatTests.cs @@ -0,0 +1,78 @@ +public class TaggedFormatTests +{ + [Test] + [Arguments("plain", false)] + [Arguments("ansi", false)] + [Arguments("html", true)] + [Arguments("mxp", true)] + [Arguments("pueblo", true)] + [Arguments("bbcode", false)] + [Arguments("markdown", false)] + public async Task BuiltInsDeclareTheirAngleBracketSyntax(string name, bool tagged) + => await Assert.That(MarkupFormat.TryParse(name)!.IsTagged).IsEqualTo(tagged); + + [Test] + [Arguments(TextEncoding.None)] + [Arguments(TextEncoding.StripControls)] + [Arguments(TextEncoding.Html)] + [Arguments(TextEncoding.HtmlLineBreaks)] + [Arguments(TextEncoding.Markdown)] + public async Task LiteralTextEncodingDoesNotOptACustomFormatIntoTagParsing(TextEncoding encoding) + { + var format = MarkupFormat.Custom("custom", encoding); + await Assert.That(format.IsTagged).IsFalse(); + await Assert.That(Width("&", format)).IsEqualTo(12); + } + + [Test] + [Arguments(false, 12)] + [Arguments(true, 1)] + public async Task CustomFormatsExplicitlyChooseTheTaggedWidthGrammar(bool tagged, int expected) + { + var format = MarkupFormat.Custom("my-format", TextEncoding.Html, isTagged: tagged); + await Assert.That(format.IsTagged).IsEqualTo(tagged); + await Assert.That(Width("&", format)).IsEqualTo(expected); + } + + [Test] + public async Task TaggedMeansTheDocumentedAngleBracketGrammarNotArbitraryTags() + { + var format = MarkupFormat.Custom("angle-tags", TextEncoding.Html, isTagged: true); + await Assert.That(Width("\e[31m&界\e[0m", format)).IsEqualTo(3); + await Assert.That(Width("[b]x[/b]", format)).IsEqualTo(8); + await Assert.That(Width("**x**", format)).IsEqualTo(5); + } + + [Test] + [Arguments("HTML")] + [Arguments("MXP")] + [Arguments("Pueblo")] + [Arguments("plain")] + public async Task TaggedCustomFormatsCannotOverrideBuiltInDeclarations(string name) + => await Assert.That(() => MarkupFormat.Custom(name, TextEncoding.None, isTagged: true)).Throws(); + + [Test] + public async Task TaggedCustomFormatIdentityStillUsesItsName() + { + var first = MarkupFormat.Custom("custom-tags", TextEncoding.Html, isTagged: true); + var second = MarkupFormat.Custom("CUSTOM-TAGS", TextEncoding.Html, isTagged: true); + await Assert.That(first).IsEqualTo(second); + await Assert.That(first.GetHashCode()).IsEqualTo(second.GetHashCode()); + } + + [Test] + [Arguments(0)] + [Arguments(7)] + public async Task SourceGeometryStillWinsOverCustomTaggedParsing(int width) + { + var context = new EmitContext + { + Format = MarkupFormat.Custom("custom-tags", TextEncoding.Html, isTagged: true), + Registry = MarkupRegistry.Empty, Markups = MarkupSet.Of(new ImageMarkup("cat.png")), SourceDisplayWidth = width, + }; + await Assert.That(DisplayWidth.Of("&", context)).IsEqualTo(width); + } + + private static int Width(string body, MarkupFormat format) + => DisplayWidth.Of(body, new EmitContext { Format = format, Registry = MarkupRegistry.Empty, Markups = MarkupSet.Of(new ImageMarkup("cat.png")) }); +} diff --git a/MarkupString/DisplayWidth.Formatted.cs b/MarkupString/DisplayWidth.Formatted.cs index d96ecf5..622b5c8 100644 --- a/MarkupString/DisplayWidth.Formatted.cs +++ b/MarkupString/DisplayWidth.Formatted.cs @@ -38,21 +38,20 @@ public static int OfTagged(ReadOnlySpan text) => /// /// Cells reserved by the run's original source when - /// is supplied; otherwise measures using the context's known format. + /// is supplied; otherwise measures using the context's format capabilities. /// /// /// Source geometry wins even when an inner emitter replaced the text, and zero is a supplied width. - /// Without it, ANSI uses and HTML, MXP and Pueblo use . - /// Plain text, Markdown, BBCode and custom formats retain literal - /// measurement; there is no Markdown, BBCode or custom-format parser here. A manually constructed - /// context should supply source geometry when its body cannot be measured by those fallbacks. + /// Without it, ANSI uses and formats declaring + /// use . Other formats retain literal + /// measurement; there is no Markdown or BBCode parser here. A manually constructed context should + /// supply source geometry when its body cannot be measured by those fallbacks. /// public static int Of(ReadOnlySpan body, in EmitContext context) { if (context.SourceDisplayWidth is { } width) return width; if (context.Format == MarkupFormat.Ansi) return OfAnsi(body); - if (context.Format == MarkupFormat.Html || context.Format == MarkupFormat.Mxp || context.Format == MarkupFormat.Pueblo) - return OfTagged(body); + if (context.Format.IsTagged) return OfTagged(body); return Of(body); } diff --git a/MarkupString/MarkupFormat.cs b/MarkupString/MarkupFormat.cs index 8897735..84d850b 100644 --- a/MarkupString/MarkupFormat.cs +++ b/MarkupString/MarkupFormat.cs @@ -2,34 +2,55 @@ namespace MarkupString; /// /// An output format a can be rendered to. Two formats are equal when -/// their s match ordinally, ignoring case. refuses a name +/// their s match ordinally, ignoring case. refuses a name /// that matches one of the seven built-ins, so a caller cannot accidentally address a built-in's -/// registry slots under a different . +/// registry slots under different format metadata. Custom declarations with the same name must use +/// consistent and values; identity remains name-based. /// public sealed class MarkupFormat : IEquatable { public string Name { get; } public TextEncoding Encoding { get; } - private MarkupFormat(string name, TextEncoding encoding) + /// + /// Whether fragments use angle-bracket tags and comments plus HTML-style entities, the lexical + /// grammar measured by . This does not mean every kind of tag: + /// BBCode's square-bracket tags and Markdown syntax do not qualify. + /// + /// + /// Independent of , which only escapes literal source text. This capability + /// describes the common fragment syntax, not a format's full semantics, browser layout or protocol. + /// + public bool IsTagged { get; } + + private MarkupFormat(string name, TextEncoding encoding, bool isTagged = false) { Name = name; Encoding = encoding; + IsTagged = isTagged; } public static readonly MarkupFormat Plain = new("plain", TextEncoding.StripControls); public static readonly MarkupFormat Ansi = new("ansi", TextEncoding.None); - public static readonly MarkupFormat Html = new("html", TextEncoding.Html); - public static readonly MarkupFormat Pueblo = new("pueblo", TextEncoding.HtmlLineBreaks); - public static readonly MarkupFormat Mxp = new("mxp", TextEncoding.Html); + public static readonly MarkupFormat Html = new("html", TextEncoding.Html, isTagged: true); + public static readonly MarkupFormat Pueblo = new("pueblo", TextEncoding.HtmlLineBreaks, isTagged: true); + public static readonly MarkupFormat Mxp = new("mxp", TextEncoding.Html, isTagged: true); public static readonly MarkupFormat BBCode = new("bbcode", TextEncoding.StripControls); /// GitHub-flavoured Markdown, as a wiki or a README stores it. See . public static readonly MarkupFormat Markdown = new("markdown", TextEncoding.Markdown); - /// Declares a format outside the built-in seven. + /// Declares a format outside the built-in seven, without opting into tagged-fragment parsing. + /// matches a built-in format's name, ignoring case. + public static MarkupFormat Custom(string name, TextEncoding encoding) => Custom(name, encoding, isTagged: false); + + /// Declares a custom format with an explicit capability. + /// The format's identity. Use consistent metadata for declarations sharing a name. + /// How literal source text is escaped; does not determine the fragment grammar. + /// Whether fragments use the angle-bracket and entity grammar documented by + /// . Leave false for other grammars, including square-bracket tags. /// matches a built-in format's name, ignoring case. - public static MarkupFormat Custom(string name, TextEncoding encoding) + public static MarkupFormat Custom(string name, TextEncoding encoding, bool isTagged) { ArgumentException.ThrowIfNullOrWhiteSpace(name); var builtIn = TryParse(name); @@ -37,7 +58,7 @@ public static MarkupFormat Custom(string name, TextEncoding encoding) { throw new ArgumentException($"'{name}' names a built-in format; use MarkupFormat.{builtIn.Name} or pick a different name.", nameof(name)); } - return new MarkupFormat(name, encoding); + return new MarkupFormat(name, encoding, isTagged); } /// Resolves one of the seven built-in formats by name, ignoring case. Unknown names give . diff --git a/MarkupString/PublicAPI.Unshipped.txt b/MarkupString/PublicAPI.Unshipped.txt index c6666e6..2a4a734 100644 --- a/MarkupString/PublicAPI.Unshipped.txt +++ b/MarkupString/PublicAPI.Unshipped.txt @@ -832,3 +832,5 @@ MarkupString.EmitContext.SourceDisplayWidth.init -> void static MarkupString.DisplayWidth.OfAnsi(System.ReadOnlySpan text) -> int static MarkupString.DisplayWidth.OfTagged(System.ReadOnlySpan text) -> int static MarkupString.DisplayWidth.Of(System.ReadOnlySpan body, in MarkupString.EmitContext context) -> int +MarkupString.MarkupFormat.IsTagged.get -> bool +static MarkupString.MarkupFormat.Custom(string! name, MarkupString.TextEncoding encoding, bool isTagged) -> MarkupString.MarkupFormat! diff --git a/README.md b/README.md index 38535c0..405e0ba 100644 --- a/README.md +++ b/README.md @@ -196,8 +196,12 @@ image dimensions, or terminal cursor motion. They do not parse Markdown or BBCod Inside an emitter, `DisplayWidth.Of(body, context)` uses `context.SourceDisplayWidth` when available, so a picture keeps its reserved source cells even if an inner emitter replaced the text. Without -source geometry it selects the ANSI or tagged helper for known formats; Plain, Markdown, BBCode, -and custom formats keep literal measurement. The renderer supplies source geometry for every run. +source geometry it selects the ANSI helper for ANSI or the tagged helper when `format.IsTagged` is +true. This capability specifically means angle-bracket tags/comments and HTML-style entities, not +BBCode or an arbitrary tag grammar. HTML, MXP and Pueblo declare it; custom formats can opt in with +`MarkupFormat.Custom("my-format", TextEncoding.Html, isTagged: true)`. The existing two-argument +`Custom` overload stays untagged regardless of literal-text encoding. Other formats keep literal +measurement. The renderer supplies source geometry for every run. That distinction powers grapheme-safe wrapping, truncation, padding, alignment, and multi-column layouts: From b0aa5c098f87bc4221e624bd9a916353804cb7aa Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Mon, 9 Nov 2026 01:56:58 -0600 Subject: [PATCH 5/5] Document width scanner and picture emission contracts --- MarkupString.Ansi/Emitters/ElementEmitters.cs | 2 ++ MarkupString.Html/Emitters/ElementHtmlEmitter.cs | 1 + MarkupString.Mxp/Emitters/ElementMxpEmitter.cs | 1 + MarkupString.Pueblo/Emitters/ElementPuebloEmitter.cs | 1 + MarkupString.Tests/ContextDisplayWidthTests.cs | 1 + MarkupString.Tests/Layout/FigurePictureTests.cs | 3 +++ MarkupString/DisplayWidth.Formatted.cs | 4 ++++ 7 files changed, 13 insertions(+) diff --git a/MarkupString.Ansi/Emitters/ElementEmitters.cs b/MarkupString.Ansi/Emitters/ElementEmitters.cs index f58b1c1..5bf1cd3 100644 --- a/MarkupString.Ansi/Emitters/ElementEmitters.cs +++ b/MarkupString.Ansi/Emitters/ElementEmitters.cs @@ -28,6 +28,7 @@ internal sealed class ImageBBCodeEmitter : IMarkupEmitter public MarkupFormat Format => MarkupFormat.BBCode; + /// Writes one BBCode image and reserves its source-width cells on each figure row; an unrepresentable address keeps the body. public void Emit(IMarkup markup, ReadOnlySpan body, in EmitContext context, IBufferWriter output) { ArgumentNullException.ThrowIfNull(output); @@ -62,6 +63,7 @@ internal sealed class ImageMarkdownEmitter : IMarkupEmitter public MarkupFormat Format => MarkupFormat.Markdown; + /// Writes one safe Markdown image and reserves its source-width cells on each figure row; a rejected address keeps the body. public void Emit(IMarkup markup, ReadOnlySpan body, in EmitContext context, IBufferWriter output) { ArgumentNullException.ThrowIfNull(output); diff --git a/MarkupString.Html/Emitters/ElementHtmlEmitter.cs b/MarkupString.Html/Emitters/ElementHtmlEmitter.cs index b9a20ef..d2b7f31 100644 --- a/MarkupString.Html/Emitters/ElementHtmlEmitter.cs +++ b/MarkupString.Html/Emitters/ElementHtmlEmitter.cs @@ -43,6 +43,7 @@ private enum Shape public MarkupFormat Format => MarkupFormat.Html; + /// Writes the shared element under the HTML tag policy, preserving fallback text and the source cells reserved by image rows. public void Emit(IMarkup markup, ReadOnlySpan body, in EmitContext context, IBufferWriter output) { ArgumentNullException.ThrowIfNull(markup); diff --git a/MarkupString.Mxp/Emitters/ElementMxpEmitter.cs b/MarkupString.Mxp/Emitters/ElementMxpEmitter.cs index 3295c53..eac621a 100644 --- a/MarkupString.Mxp/Emitters/ElementMxpEmitter.cs +++ b/MarkupString.Mxp/Emitters/ElementMxpEmitter.cs @@ -24,6 +24,7 @@ internal sealed class ElementMxpEmitter(Type markupType, Func? sup public MarkupFormat Format => MarkupFormat.Mxp; + /// Writes supported MXP elements or their fallbacks, preserving the source cells reserved by image rows. public void Emit(IMarkup markup, ReadOnlySpan body, in EmitContext context, IBufferWriter output) { ArgumentNullException.ThrowIfNull(markup); diff --git a/MarkupString.Pueblo/Emitters/ElementPuebloEmitter.cs b/MarkupString.Pueblo/Emitters/ElementPuebloEmitter.cs index 9f81117..074eb40 100644 --- a/MarkupString.Pueblo/Emitters/ElementPuebloEmitter.cs +++ b/MarkupString.Pueblo/Emitters/ElementPuebloEmitter.cs @@ -28,6 +28,7 @@ internal sealed class ElementPuebloEmitter(Type markupType) : IMarkupEmitter public MarkupFormat Format => MarkupFormat.Pueblo; + /// Writes Pueblo elements and their fallbacks, preserving the source cells reserved by image rows. public void Emit(IMarkup markup, ReadOnlySpan body, in EmitContext context, IBufferWriter output) { ArgumentNullException.ThrowIfNull(markup); diff --git a/MarkupString.Tests/ContextDisplayWidthTests.cs b/MarkupString.Tests/ContextDisplayWidthTests.cs index 91b9fab..11a1585 100644 --- a/MarkupString.Tests/ContextDisplayWidthTests.cs +++ b/MarkupString.Tests/ContextDisplayWidthTests.cs @@ -6,6 +6,7 @@ public class ContextDisplayWidthTests { + /// Resolves a built-in format or creates an untagged custom format despite its HTML text encoding. private static MarkupFormat Format(string name) => MarkupFormat.TryParse(name) ?? MarkupFormat.Custom(name, TextEncoding.Html); diff --git a/MarkupString.Tests/Layout/FigurePictureTests.cs b/MarkupString.Tests/Layout/FigurePictureTests.cs index d9bf810..9bce7ac 100644 --- a/MarkupString.Tests/Layout/FigurePictureTests.cs +++ b/MarkupString.Tests/Layout/FigurePictureTests.cs @@ -43,6 +43,7 @@ public async Task MxpWritesTheFiguresPictureOnceAndKeepsItsCells() await Assert.That(lines.Length).IsEqualTo(3); } + /// Verifies that styling syntax never enlarges the picture placeholder or moves its neighboring text. [Test] [Arguments("mxp", AnsiColorDepth.Standard, true)] [Arguments("mxp", AnsiColorDepth.TrueColor, true)] @@ -66,6 +67,7 @@ public async Task StyledPictureRowsKeepTheirOriginalCells(string format, AnsiCol } } + /// Verifies source-cell padding across differently styled runs containing entities, CJK text and combining marks. [Test] [Arguments("mxp")] [Arguments("pueblo")] @@ -83,6 +85,7 @@ public async Task SplitPictureRowsMeasureSourceCellsNotEncodedEntities(string fo await Assert.That(plain).IsEqualTo(new string(' ', 7) + "END"); } + /// Reproduces the two reported banner widths and verifies that every rule stays within its negotiated row. [Test] [Arguments(117, AnsiColorDepth.Standard)] [Arguments(134, AnsiColorDepth.TrueColor)] diff --git a/MarkupString/DisplayWidth.Formatted.cs b/MarkupString/DisplayWidth.Formatted.cs index 622b5c8..dbdd35f 100644 --- a/MarkupString/DisplayWidth.Formatted.cs +++ b/MarkupString/DisplayWidth.Formatted.cs @@ -55,6 +55,7 @@ public static int Of(ReadOnlySpan body, in EmitContext context) return Of(body); } + /// Collects visible text through the shared scanners, then measures it as one Unicode sequence. private static int OfFormatted(ReadOnlySpan text, bool tagged) { using var plain = new PooledCharWriter(Math.Min(text.Length, 256)); @@ -71,6 +72,7 @@ private sealed class WidthSink(PooledCharWriter plain, bool tagged) : IVtTokenSi private readonly TagScanner? _tags = tagged ? new TagScanner() : null; public bool ReadsMarkup => true; + /// Collects literal text directly, or passes it through the tag scanner when tagged syntax is enabled. public void Print(ReadOnlySpan text) { if (_tags is null) plain.Write(text); @@ -86,6 +88,7 @@ public void Execute(char control) public void Text(ReadOnlySpan text) => plain.Write(text); public void Tag(ReadOnlySpan body) { } + /// Decodes a known entity once and preserves an unknown entity as literal text. public void Entity(ReadOnlySpan name) { if (TagSyntax.Entity(name) is { } decoded) plain.Write(decoded); @@ -103,6 +106,7 @@ public void Osc(ReadOnlySpan payload) { } public void Dcs(in CsiSequence header, ReadOnlySpan data) { } public void Apc(ReadOnlySpan payload) { } public void Abandoned(VtSequenceKind kind) { } + /// Flushes an incomplete tag or entity as literal text before the combined text is measured. public void Finish() => _tags?.Flush(this); } }