diff --git a/CHANGELOG.md b/CHANGELOG.md index dba2c52..6fecba1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/MarkupString.Ansi/Emitters/ElementEmitters.cs b/MarkupString.Ansi/Emitters/ElementEmitters.cs index bb80773..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); @@ -48,7 +49,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(' ', DisplayWidth.Of(body, context))); } } @@ -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); @@ -76,6 +78,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(' ', DisplayWidth.Of(body, context))); } } diff --git a/MarkupString.Html/Emitters/ElementHtmlEmitter.cs b/MarkupString.Html/Emitters/ElementHtmlEmitter.cs index 4868009..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); @@ -107,7 +108,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(' ', DisplayWidth.Of(body, context))); return; } diff --git a/MarkupString.Mxp/Emitters/ElementMxpEmitter.cs b/MarkupString.Mxp/Emitters/ElementMxpEmitter.cs index 788c7b5..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); @@ -90,7 +91,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(' ', DisplayWidth.Of(body, context))); return; } diff --git a/MarkupString.Pueblo/Emitters/ElementPuebloEmitter.cs b/MarkupString.Pueblo/Emitters/ElementPuebloEmitter.cs index 21e5436..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); @@ -74,7 +75,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(' ', 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..11a1585 --- /dev/null +++ b/MarkupString.Tests/ContextDisplayWidthTests.cs @@ -0,0 +1,83 @@ +using System.Buffers; +using MarkupString.Ansi; +using MarkupString.Html; +using MarkupString.Mxp; +using MarkupString.Pueblo; + +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); + + [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.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.Tests/Layout/FigurePictureTests.cs b/MarkupString.Tests/Layout/FigurePictureTests.cs index ed04ae7..9bce7ac 100644 --- a/MarkupString.Tests/Layout/FigurePictureTests.cs +++ b/MarkupString.Tests/Layout/FigurePictureTests.cs @@ -43,6 +43,71 @@ 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)] + [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"); + } + } + + /// Verifies source-cell padding across differently styled runs containing entities, CJK text and combining marks. + [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"); + } + + /// 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)] + 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.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 new file mode 100644 index 0000000..dbdd35f --- /dev/null +++ b/MarkupString/DisplayWidth.Formatted.cs @@ -0,0 +1,112 @@ +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); + + /// + /// Cells reserved by the run's original source when + /// 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 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.IsTagged) return OfTagged(body); + 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)); + 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; + + /// 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); + 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) { } + /// 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); + 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) { } + /// Flushes an incomplete tag or entity as literal text before the combined text is measured. + 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 64e4c41..970e7d3 100644 --- a/MarkupString/EmitContext.cs +++ b/MarkupString/EmitContext.cs @@ -17,6 +17,14 @@ 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. 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? 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/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/MarkupTextRenderer.cs b/MarkupString/MarkupTextRenderer.cs index f12908a..561a3e5 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, + 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 c23c282..2a4a734 100644 --- a/MarkupString/PublicAPI.Unshipped.txt +++ b/MarkupString/PublicAPI.Unshipped.txt @@ -826,3 +826,11 @@ 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.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 +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 eb8f9bf..405e0ba 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,21 @@ 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. + +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 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: