From 67166867e2f7d7f46afcfb203ed0120d5488e3b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:48:49 +0000 Subject: [PATCH 1/2] A picture is one ImageMarkup, laid out or inline figure() reached an MXP client as text art alone. Figure.Draw marked its picture only for a terminal that draws pictures in cells (PictureCellsMarkup, under LayoutContext.Pictures), so MXP, Pueblo and BBCode had no picture to write. PictureCellsMarkup is folded into ImageMarkup as an optional Row (PictureRow: row, rows, columns), and a figure with an address always marks its rows with it. MXP, Pueblo, HTML and BBCode write the element once (ImageMarkup.StartsPicture) and keep the figure's other cells blank, so text beside it stays put; a client that refuses the element, or an HTML policy that refuses , reads the art. The ANSI terminal path is unchanged apart from reading the row off ImageMarkup. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018ztDNVgzxM8V5WKWBRs2EW --- CHANGELOG.md | 19 ++- MarkupString.Ansi/Emitters/AnsiSetEmitter.cs | 13 +- MarkupString.Ansi/Emitters/ElementEmitters.cs | 16 +- .../Emitters/TerminalPictureWriter.cs | 14 +- MarkupString.Ansi/README.md | 6 +- .../Emitters/ElementHtmlEmitter.cs | 65 ++++--- .../Emitters/ElementMxpEmitter.cs | 20 ++- MarkupString.Mxp/README.md | 1 + .../Emitters/ElementPuebloEmitter.cs | 18 +- .../Ansi/TerminalFeatureTests.cs | 12 +- .../Layout/FigurePictureTests.cs | 161 ++++++++++++++++++ MarkupString/Elements/ElementCodecs.cs | 13 +- MarkupString/Elements/ImageMarkup.cs | 28 ++- MarkupString/Elements/PictureCellsMarkup.cs | 19 --- MarkupString/Elements/PictureRow.cs | 17 ++ MarkupString/Layout/Blocks/Block.cs | 2 +- MarkupString/Layout/Blocks/Figure.cs | 33 +++- MarkupString/PublicAPI.Unshipped.txt | 37 ++-- docs/layout.md | 2 +- 19 files changed, 380 insertions(+), 116 deletions(-) create mode 100644 MarkupString.Tests/Layout/FigurePictureTests.cs delete mode 100644 MarkupString/Elements/PictureCellsMarkup.cs create mode 100644 MarkupString/Elements/PictureRow.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2043336..33fa256 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,8 +18,8 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). advertising MTTS's MSLP bit sends back when clicked. A command holding a control character is written as its text. - **Pictures in a figure's cells.** A `Figure` laid out under `LayoutContext.Pictures` reserves the - cells its picture takes (its art's, or `PictureCells` for one with none) and marks each row with - `PictureCellsMarkup`. In `MarkupFormat.Ansi` such a row is drawn as the picture for a client with + cells its picture takes (its art's, or `PictureCells` for one with none) and marks each row with its + `ImageMarkup`, `Row` set to a `PictureRow`. In `MarkupFormat.Ansi` such a row is drawn as the picture for a client with `KittyGraphics` (Unicode placeholders, the picture sent once per connection), `InlineImages` (iTerm2), `Sixel`, or `BlockArt` (half blocks), and as the art otherwise. The rows are the same width either way, so a box or a flex row around the figure stays aligned. @@ -42,8 +42,23 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). the terminal takes in one sequence (a mebibyte, for iTerm2) is sent in parts where it reads them (`MultipartFile`), and a moving one is sent still where it does not. +### Changed + +- **A picture is one `ImageMarkup`, laid out or inline.** `PictureCellsMarkup` is gone: a figure's rows + carry its `ImageMarkup` with `Row` (a `PictureRow`: which row, how many, how many cells wide), and the + serialiser keeps it. MXP, Pueblo, HTML and BBCode write a picture's element once + (`ImageMarkup.StartsPicture`): at the start of an inline picture, however many runs its description is + in, or on a figure's first row. + ### Fixed +- **A figure's picture reaches MXP, Pueblo and BBCode.** A `Figure` laid out as text marked its picture + only for a terminal that draws pictures in its cells, so every other format wrote the art alone and an + MXP client was never sent ``. Its rows (or its `[description]`) now always carry the picture + when it has an address: a format with a picture element writes it on the first row and keeps the + figure's cells blank, so text beside it stays in its column, and a client that refuses the element, or + an HTML policy that refuses ``, reads the art. A terminal without the picture's pixels still writes + the art. - **A box round a picture fits it in HTML.** The `` of a `Figure` sat on the text baseline, leaving a band the height of a descender below it, and the box's side padding left gaps left and right but none above. The picture now aligns to the top of its line, and a box whose body is only a diff --git a/MarkupString.Ansi/Emitters/AnsiSetEmitter.cs b/MarkupString.Ansi/Emitters/AnsiSetEmitter.cs index 8724a0a..edaf826 100644 --- a/MarkupString.Ansi/Emitters/AnsiSetEmitter.cs +++ b/MarkupString.Ansi/Emitters/AnsiSetEmitter.cs @@ -9,7 +9,7 @@ namespace MarkupString.Ansi; /// /// /// Links and pictures are written as the client's allow: a URL link -/// as OSC 8, a command link as MSLP, a row of a picture's cells () as the +/// as OSC 8, a command link as MSLP, a row of a figure's picture () as the /// picture. Without the feature, a link is its text and a picture its text art. /// /// What the client is sent: its colour depth and what else its terminal can do. @@ -56,7 +56,7 @@ public bool TryEmit(MarkupSet set, ReadOnlySpan body, in EmitContext conte { // The run that starts the row draws all of it; the others in the same row draw nothing, so the row is // as many cells as the text it stands over. - if (context.StartsRegion(picture.Cells)) + if (context.StartsRegion(picture.Image)) TerminalPictureWriter.Write(_pictureMethod, picture.Cells, picture.Pixels, effective, _options, core, output); } else @@ -73,15 +73,16 @@ public bool TryEmit(MarkupSet set, ReadOnlySpan body, in EmitContext conte } /// The picture this run is a row of, when this client draws it and its pixels are to hand. - private (PictureCellsMarkup Cells, TerminalPicture Pixels)? Picture(MarkupSet set) + private (ImageMarkup Image, PictureRow Cells, TerminalPicture Pixels)? Picture(MarkupSet set) { if (_pictureMethod == TerminalFeatures.None) return null; for (var i = 0; i < set.Count; i++) { - if (set[i] is not PictureCellsMarkup cells) continue; + if (set[i] is not ImageMarkup { Row: { } cells } image) continue; + // The pixels are looked up by the picture alone: every row of it is the same picture. return TerminalPictureWriter.CanDraw(_pictureMethod, cells) - && _options.Pictures!.TryGetPicture(cells.Image, out var pixels) - ? (cells, pixels) + && _options.Pictures!.TryGetPicture(image with { Row = null }, out var pixels) + ? (image, cells, pixels) : null; } return null; diff --git a/MarkupString.Ansi/Emitters/ElementEmitters.cs b/MarkupString.Ansi/Emitters/ElementEmitters.cs index bdafcf9..40bdca4 100644 --- a/MarkupString.Ansi/Emitters/ElementEmitters.cs +++ b/MarkupString.Ansi/Emitters/ElementEmitters.cs @@ -33,15 +33,21 @@ public void Emit(IMarkup markup, ReadOnlySpan body, in EmitContext context ArgumentNullException.ThrowIfNull(output); // A bracket would close the tag early, and BBCode has no way to escape one. - var source = ((ImageMarkup)markup).Source; - if (source.AsSpan().IndexOfAny('[', ']') >= 0) + var image = (ImageMarkup)markup; + if (image.Source.AsSpan().IndexOfAny('[', ']') >= 0) { output.Write(body); return; } - output.Write("[img]"); - output.Write(source); - output.Write("[/img]"); + if (image.StartsPicture(context)) + { + output.Write("[img]"); + output.Write(image.Source); + output.Write("[/img]"); + } + + // 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))); } } diff --git a/MarkupString.Ansi/Emitters/TerminalPictureWriter.cs b/MarkupString.Ansi/Emitters/TerminalPictureWriter.cs index 8743525..19f5795 100644 --- a/MarkupString.Ansi/Emitters/TerminalPictureWriter.cs +++ b/MarkupString.Ansi/Emitters/TerminalPictureWriter.cs @@ -4,7 +4,7 @@ namespace MarkupString.Ansi; /// -/// Draws one row of a picture's cells () in a terminal, in whichever way +/// Draws one row of a figure's picture () in a terminal, in whichever way /// says the client draws pictures. /// /// @@ -64,7 +64,7 @@ internal static TerminalFeatures Method(TerminalFeatures features) => : TerminalFeatures.None; /// Whether can be drawn by at all. - internal static bool CanDraw(TerminalFeatures method, PictureCellsMarkup cells) => + internal static bool CanDraw(TerminalFeatures method, PictureRow cells) => cells.Columns > 0 && cells.Rows > 0 && cells.Row >= 0 && cells.Row < cells.Rows && (method != TerminalFeatures.KittyGraphics || (cells.Columns <= Diacritics.Length && cells.Rows <= Diacritics.Length)); @@ -84,7 +84,7 @@ internal static bool CanDraw(TerminalFeatures method, PictureCellsMarkup cells) /// internal static void Write( TerminalFeatures method, - PictureCellsMarkup cells, + PictureRow cells, TerminalPicture picture, in AnsiStyle effective, AnsiOutputOptions options, @@ -111,7 +111,7 @@ internal static void Write( /// its size in cells, since each size is its own virtual placement. 24 bits, so it fits a truecolor /// foreground; never zero, which Kitty reads as no id. /// - internal static uint KittyId(TerminalPicture picture, PictureCellsMarkup cells) + internal static uint KittyId(TerminalPicture picture, PictureRow cells) { var hash = 2166136261u; foreach (var c in picture.Key) hash = (hash ^ c) * 16777619u; @@ -121,7 +121,7 @@ internal static uint KittyId(TerminalPicture picture, PictureCellsMarkup cells) return id == 0 ? 1 : id; } - private static void WriteKitty(PictureCellsMarkup cells, TerminalPicture picture, in AnsiStyle effective, AnsiOutputOptions options, + private static void WriteKitty(PictureRow cells, TerminalPicture picture, in AnsiStyle effective, AnsiOutputOptions options, IBufferWriter output, IBufferWriter ahead) { var id = KittyId(picture, cells); @@ -243,7 +243,7 @@ private static void AppendKittyChunks(StringBuilder text, ReadOnlySpan rgb /// down past the picture), comes back up, and draws the picture between a cursor save and restore. Every row /// then steps over the picture's cells (CSI n C) rather than writing spaces that would erase it. /// - private static void WriteOverlay(TerminalFeatures method, PictureCellsMarkup cells, TerminalPicture picture, AnsiOutputOptions options, + private static void WriteOverlay(TerminalFeatures method, PictureRow cells, TerminalPicture picture, AnsiOutputOptions options, IBufferWriter output, IBufferWriter ahead) { if (cells.Row == 0) @@ -330,7 +330,7 @@ private static string EncodeSixel(TerminalPicture picture, OverlayKey key) /// foreground of ▀, the lower its background. A transparent half shows the run's own background. /// Every row of the picture is made at once and kept, since the rows are drawn one after another. /// - private static void WriteBlockArt(PictureCellsMarkup cells, TerminalPicture picture, in AnsiStyle effective, AnsiColorDepth depth, IBufferWriter output) + private static void WriteBlockArt(PictureRow cells, TerminalPicture picture, in AnsiStyle effective, AnsiColorDepth depth, IBufferWriter output) { var rows = PictureEncodings.GetOrAdd(picture, new BlockKey(cells.Columns, cells.Rows, depth, effective), EncodeBlockArt); output.Write(rows[cells.Row]); diff --git a/MarkupString.Ansi/README.md b/MarkupString.Ansi/README.md index 1002efe..5e794ad 100644 --- a/MarkupString.Ansi/README.md +++ b/MarkupString.Ansi/README.md @@ -73,9 +73,9 @@ fully. `TerminalFeatures` says what its terminal reads beyond colour: | `MovingPictures` | a moving picture played, through Kitty frames or an iTerm2 GIF, rather than its first frame | Pictures are drawn into the cells a `Figure` reserves when it is laid out for such a reader -(`LayoutContext.Pictures`, which answers the cells a picture takes). Each row is marked with -`PictureCellsMarkup` over the figure's text art, so a box, a flex row or a table around it lines up -whichever way it ends up drawn. The pixels come from the host's `ITerminalPictureSource`: this package +(`LayoutContext.Pictures`, which answers the cells a picture takes). Each row is marked with the +figure's `ImageMarkup`, its `Row` saying which row of the picture it is, over the figure's text art, so a +box, a flex row or a table around it lines up whichever way it ends up drawn. The pixels come from the host's `ITerminalPictureSource`: this package neither fetches nor decodes a file. Without the pixels, or without the feature, the row is its art. - **Kitty** sends the picture once per connection (`a=T,U=1`, a PNG in 4096-byte chunks, `q=2` so diff --git a/MarkupString.Html/Emitters/ElementHtmlEmitter.cs b/MarkupString.Html/Emitters/ElementHtmlEmitter.cs index 46c1b69..5bc7e78 100644 --- a/MarkupString.Html/Emitters/ElementHtmlEmitter.cs +++ b/MarkupString.Html/Emitters/ElementHtmlEmitter.cs @@ -85,14 +85,30 @@ public void Emit(IMarkup markup, ReadOnlySpan body, in EmitContext context return; case ImageMarkup image: - Write(markup, output, "img", Shape.Void, body, context, - ("class", "ms-image"), - ("src", image.Source), - ("alt", image.Description ?? string.Empty), - ("width", Number(image.Width)), - ("height", Number(image.Height)), - ("data-align", image.Align?.ToString().ToLowerInvariant())); - return; + { + ReadOnlySpan<(string, string?)> attributes = + [ + ("class", "ms-image"), + ("src", image.Source), + ("alt", image.Description ?? string.Empty), + ("width", Number(image.Width)), + ("height", Number(image.Height)), + ("data-align", image.Align?.ToString().ToLowerInvariant()), + ]; + + // A picture the policy refuses is its text on every run, the rows of a figure's art included. + if (Held("img", attributes) is null) + { + output.Write(body); + return; + } + + 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))); + return; + } case PreformattedMarkup: Write(markup, output, "pre", Shape.Wrapping, body, context, ("class", "ms-preformatted")); @@ -140,23 +156,10 @@ private void Write( in EmitContext context, params ReadOnlySpan<(string Name, string? Value)> attributes) { - var written = new HtmlAttribute[Count(attributes)]; - var next = 0; - foreach (var (attribute, value) in attributes) + if (Held(name, attributes) is not { } tag) { - if (value is not null) written[next++] = new HtmlAttribute(attribute, value); - } - - var tag = HtmlMarkup.Tag(name, written); - if (policy is not null) - { - if (policy.Apply(tag) is not { } held) - { - if (markup is not IPointMarkup) output.Write(body); - return; - } - - tag = held; + if (markup is not IPointMarkup) output.Write(body); + return; } // One element around the whole stretch this layer covers, however many runs its content is in. @@ -195,6 +198,20 @@ private void Write( output.Write(">"); } + /// The element as the policy lets it be written, or null for one it refuses. + private HtmlMarkup? Held(string name, ReadOnlySpan<(string Name, string? Value)> attributes) + { + var written = new HtmlAttribute[Count(attributes)]; + var next = 0; + foreach (var (attribute, value) in attributes) + { + if (value is not null) written[next++] = new HtmlAttribute(attribute, value); + } + + var tag = HtmlMarkup.Tag(name, written); + return policy is null ? tag : policy.Apply(tag); + } + private static int Count(ReadOnlySpan<(string Name, string? Value)> attributes) { var count = 0; diff --git a/MarkupString.Mxp/Emitters/ElementMxpEmitter.cs b/MarkupString.Mxp/Emitters/ElementMxpEmitter.cs index cdd18a6..e43d54d 100644 --- a/MarkupString.Mxp/Emitters/ElementMxpEmitter.cs +++ b/MarkupString.Mxp/Emitters/ElementMxpEmitter.cs @@ -60,13 +60,19 @@ public void Emit(IMarkup markup, ReadOnlySpan body, in EmitContext context return; } - var (file, directory) = Split(image.Source); - tag.Open("IMAGE").Positional(file) - .Named("URL", directory) - .Named("W", image.Width) - .Named("H", image.Height) - .Named("ALIGN", image.Align?.ToString().ToUpperInvariant()) - .Close(); + if (image.StartsPicture(context)) + { + var (file, directory) = Split(image.Source); + tag.Open("IMAGE").Positional(file) + .Named("URL", directory) + .Named("W", image.Width) + .Named("H", image.Height) + .Named("ALIGN", image.Align?.ToString().ToUpperInvariant()) + .Close(); + } + + // 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))); return; } diff --git a/MarkupString.Mxp/README.md b/MarkupString.Mxp/README.md index d62a3f1..88d0f6c 100644 --- a/MarkupString.Mxp/README.md +++ b/MarkupString.Mxp/README.md @@ -56,6 +56,7 @@ written. Asking belongs to the telnet layer, and the answer to one connection. | `MarkupText.Music(source, volume, repeats, continues)` | `` | | `MarkupText.StopSound(channel)` | ``, `` | | `MarkupText.Image(source, description, width, height, align)` | `` | +| a laid-out `Figure` | `` on its first row, its other cells blank | | `MarkupText.Pane(content, name, title)` | `content` | | `MarkupText.ExpireLinks(group)` | `` | | `MarkupText.Relocate(host, port, quiet)` | `` | diff --git a/MarkupString.Pueblo/Emitters/ElementPuebloEmitter.cs b/MarkupString.Pueblo/Emitters/ElementPuebloEmitter.cs index 2a21c8f..21e5436 100644 --- a/MarkupString.Pueblo/Emitters/ElementPuebloEmitter.cs +++ b/MarkupString.Pueblo/Emitters/ElementPuebloEmitter.cs @@ -63,12 +63,18 @@ public void Emit(IMarkup markup, ReadOnlySpan body, in EmitContext context return; case ImageMarkup image: - Tag(output, "img", - ("src", image.Source), - ("alt", image.Description ?? string.Empty), - ("width", image.Width?.ToString(CultureInfo.InvariantCulture)), - ("height", image.Height?.ToString(CultureInfo.InvariantCulture)), - ("align", image.Align?.ToString().ToLowerInvariant())); + if (image.StartsPicture(context)) + { + Tag(output, "img", + ("src", image.Source), + ("alt", image.Description ?? string.Empty), + ("width", image.Width?.ToString(CultureInfo.InvariantCulture)), + ("height", image.Height?.ToString(CultureInfo.InvariantCulture)), + ("align", image.Align?.ToString().ToLowerInvariant())); + } + + // 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))); return; case PreformattedMarkup: diff --git a/MarkupString.Tests/Ansi/TerminalFeatureTests.cs b/MarkupString.Tests/Ansi/TerminalFeatureTests.cs index eff5244..ca268e6 100644 --- a/MarkupString.Tests/Ansi/TerminalFeatureTests.cs +++ b/MarkupString.Tests/Ansi/TerminalFeatureTests.cs @@ -104,7 +104,7 @@ public async Task AFigureForAReaderWithPicturesMarksEachRowOfItsArt() var laid = Laid(figure, 20); await Assert.That(laid.ToPlainText()).IsEqualTo(BlockLayout.Build(figure, 20).ToPlainText()); - var rows = laid.Runs.SelectMany(run => run.Markups).OfType().Distinct().ToArray(); + var rows = laid.Runs.SelectMany(run => run.Markups).OfType().Select(image => image.Row!.Value).Distinct().ToArray(); await Assert.That(rows.Select(r => (r.Row, r.Rows, r.Columns))).IsEquivalentTo(new[] { (0, 2, 7), (1, 2, 7) }); } @@ -118,6 +118,10 @@ public async Task AFigureWithNoArtReservesTheCellsItIsGiven() await Assert.That(lines.Select(l => l.TrimEnd())).IsEquivalentTo(new[] { "", "[A cat]", "" }); } + /// + /// A reader who gets no picture still has it marked on the art's rows, for a format that writes it as an + /// element of its own; the cells are the art's and the text is the art. + /// [Test] public async Task AFigureIsItsArtWhenTheReaderGetsNoPicture() { @@ -125,7 +129,11 @@ public async Task AFigureIsItsArtWhenTheReaderGetsNoPicture() var laid = BlockLayout.Build(figure, 20, context: new LayoutContext { Pictures = (_, _) => null }); - await Assert.That(laid.Runs.SelectMany(run => run.Markups).OfType()).IsEmpty(); + await Assert.That(laid.ToPlainText().TrimEnd()).IsEqualTo("=^.^="); + await Assert.That(laid.Runs.SelectMany(run => run.Markups).OfType().Select(image => image.Row)) + .IsEquivalentTo(new PictureRow?[] { new PictureRow(0, 1, 5) }); + await Assert.That(RenderString(laid, new AnsiOutputOptions(Features: TerminalFeatures.KittyGraphics) { Pictures = new Source(null) }).TrimEnd()) + .IsEqualTo("=^.^="); } [Test] diff --git a/MarkupString.Tests/Layout/FigurePictureTests.cs b/MarkupString.Tests/Layout/FigurePictureTests.cs new file mode 100644 index 0000000..a039a82 --- /dev/null +++ b/MarkupString.Tests/Layout/FigurePictureTests.cs @@ -0,0 +1,161 @@ +using MarkupString.Ansi; +using MarkupString.Html; +using MarkupString.Layout; +using MarkupString.Mxp; +using MarkupString.Pueblo; +namespace MarkupString.Tests.Layout; + +/// +/// A figure's picture is one over the rows it is laid out in, whoever reads them. A +/// format with a picture element of its own writes it once and keeps the cells; one without writes the art. +/// +/// +/// figure() in SharpMUSH reached an MXP client as its text art alone: the picture was marked on its +/// rows only for a terminal that draws pictures in its cells, so <IMAGE> had nothing to stand on. +/// +public class FigurePictureTests +{ + private static readonly MarkupRegistry Registry = + MarkupRegistry.Empty.WithAnsi().WithHtml().WithMxp().WithPueblo(); + + private static readonly ImageMarkup Cat = new("https://example.test/img/cat.png", "A cat"); + + private static readonly MarkupText Art = MarkupText.Plain("/\\_/\\\n( o.o )\n ^ ^"); + + private static string[] Lines(MarkupText laid, MarkupFormat format, MarkupRegistry? registry = null) => + laid.Render(format, registry ?? Registry).Split('\n'); + + private static string Trimmed(string text) => string.Join('\n', text.Split('\n').Select(line => line.TrimEnd())); + + private static int Count(string text, string part) => text.Split(part).Length - 1; + + [Test] + public async Task MxpWritesTheFiguresPictureOnceAndKeepsItsCells() + { + var laid = BlockLayout.Build(new Figure(Cat, Art), 20); + + var lines = Lines(laid, MarkupFormat.Mxp); + + await Assert.That(lines[0].TrimEnd()).IsEqualTo(""); + await Assert.That(Count(string.Join('\n', lines), " line.Trim().Length == 0)).IsTrue(); + await Assert.That(lines.Length).IsEqualTo(3); + } + + [Test] + public async Task AFigureWithNoArtIsItsPictureNotItsDescription() + { + var laid = BlockLayout.Build(new Figure(Cat, MarkupText.Empty), 20); + + var mxp = laid.Render(MarkupFormat.Mxp, Registry); + + await Assert.That(mxp.TrimEnd()).IsEqualTo(""); + await Assert.That(laid.ToPlainText().TrimEnd()).IsEqualTo("[A cat]"); + } + + /// The words beside a floated picture stay in the column they are in for a reader of the art. + [Test] + public async Task TextBesideAFloatedPictureStaysWhereItWas() + { + var figure = new Figure(Cat, Art) { Float = FigureFloat.Left, Beside = new TextBlock(MarkupText.Plain("The cat sits by the fire.")) }; + var laid = BlockLayout.Build(figure, 30); + + var plain = laid.ToPlainText().Split('\n'); + var mxp = Lines(laid, MarkupFormat.Mxp); + + await Assert.That(mxp[0]).StartsWith(""); + var untagged = mxp.Select(line => line.Replace("", string.Empty)).ToArray(); + await Assert.That(untagged.Length).IsEqualTo(plain.Length); + // The art is seven cells and the gap two: the picture's cells are blank, and the words start where they did. + for (var row = 0; row < plain.Length; row++) + { + await Assert.That(untagged[row][..9].Trim()).IsEmpty(); + await Assert.That(untagged[row][9..].TrimEnd()).IsEqualTo(plain[row][9..].TrimEnd()); + } + await Assert.That(plain[0][9..]).StartsWith("The cat"); + } + + [Test] + public async Task AClientThatRefusesImagesReadsTheArt() + { + var refusing = MarkupRegistry.Empty.WithAnsi().WithMxp(element => element != "IMAGE"); + var laid = BlockLayout.Build(new Figure(Cat, Art), 20); + + await Assert.That(Trimmed(laid.Render(MarkupFormat.Mxp, refusing))).IsEqualTo(Trimmed(laid.ToPlainText())); + } + + /// figure() passes no address when the caller may not show pictures: there is no picture to mark. + [Test] + public async Task AFigureWithNoAddressIsItsArtEverywhere() + { + var laid = BlockLayout.Build(new Figure(new ImageMarkup(string.Empty, "A cat"), Art), 20); + + await Assert.That(laid.Runs.SelectMany(run => run.Markups).OfType()).IsEmpty(); + await Assert.That(Trimmed(laid.Render(MarkupFormat.Mxp, Registry))).IsEqualTo(Trimmed(laid.ToPlainText())); + } + + [Test] + public async Task PuebloAndBBCodeWriteThePictureOnce() + { + var laid = BlockLayout.Build(new Figure(Cat, Art), 20); + + await Assert.That(Count(laid.Render(MarkupFormat.Pueblo, Registry), "A picture the policy refuses is refused on every row, so the art is whole. + [Test] + public async Task HtmlHeldToAPolicyThatRefusesThePictureIsTheArt() + { + var policy = HtmlTagPolicy.WellFormed with { AllowedTags = new HashSet { "span" } }; + var registry = MarkupRegistry.Empty.WithAnsi().WithHtml(policy); + var first = MarkupText.Wrap(Cat with { Row = new PictureRow(0, 2, 7) }, "/\\_/\\"); + var second = MarkupText.Wrap(Cat with { Row = new PictureRow(1, 2, 7) }, "( o.o )"); + + await Assert.That(first.Render(MarkupFormat.Html, registry)).IsEqualTo("/\\_/\\"); + await Assert.That(second.Render(MarkupFormat.Html, registry)).IsEqualTo("( o.o )"); + } + + [Test] + public async Task ARowOfAPictureSurvivesSerialisation() + { + var laid = BlockLayout.Build(new Figure(Cat, Art), 20); + + var back = MarkupTextSerializer.Deserialize(MarkupTextSerializer.Serialize(laid, Registry), Registry); + + await Assert.That(back.Runs.SelectMany(run => run.Markups).OfType().Select(image => image.Row).Distinct()) + .IsEquivalentTo(new PictureRow?[] { new PictureRow(0, 3, 7), new PictureRow(1, 3, 7), new PictureRow(2, 3, 7) }); + await Assert.That(back.Render(MarkupFormat.Mxp, Registry)).IsEqualTo(laid.Render(MarkupFormat.Mxp, Registry)); + } + + /// A description in more than one run is still one picture. + [Test] + public async Task AnInlinePictureOverSeveralRunsIsWrittenOnce() + { + var described = MarkupText.Concat(MarkupText.Plain("A "), MarkupText.Wrap(AnsiMarkup.Create(bold: true), "cat")); + var image = MarkupText.Wrap(new ImageMarkup("cat.png", "A cat"), described); + + await Assert.That(image.Render(MarkupFormat.Mxp, Registry)).IsEqualTo(""); + await Assert.That(Count(image.Render(MarkupFormat.Pueblo, Registry), " new ImageMarkup( String(e, "s") ?? string.Empty, String(e, "d"), Int(e, "w"), Int(e, "h"), - Enum.TryParse(String(e, "a"), ignoreCase: true, out var align) ? align : null)), + Enum.TryParse(String(e, "a"), ignoreCase: true, out var align) ? align : null) + { + Row = Int(e, "r") is { } row && Int(e, "rs") is { } rows && Int(e, "c") is { } columns + ? new PictureRow(row, rows, columns) + : null, + }), new Codec("pane", static (w, m) => { diff --git a/MarkupString/Elements/ImageMarkup.cs b/MarkupString/Elements/ImageMarkup.cs index 78dff30..04817b8 100644 --- a/MarkupString/Elements/ImageMarkup.cs +++ b/MarkupString/Elements/ImageMarkup.cs @@ -21,12 +21,15 @@ public enum ImageAlign /// /// A picture. It wraps the text a client with no pictures shows instead — its description, or the -/// address when there is none. builds one. +/// address when there is none, or a figure's text art. builds one inline, +/// and a lays one out over rows of cells (). /// /// -/// MXP writes <IMAGE>, Pueblo and HTML <img>, and BBCode [img]. Every -/// other format writes the wrapped text, so a terminal still learns there was a picture and where it is. -/// Put it inside a link to make it one. +/// MXP writes <IMAGE>, Pueblo and HTML <img>, and BBCode [img], once +/// for the picture (); over a figure's rows they keep the rest of its cells blank. +/// A terminal draws a figure's rows in its cells when its host has the picture's pixels. Every other format, +/// and a client that refuses the picture, writes the wrapped text, so a terminal still learns there was a +/// picture and where it is. Put it inside a link to make it one. /// /// The picture: a file name the client resolves against the game's own image directory, or an absolute address. /// What the picture shows, for a reader who cannot see it. @@ -38,4 +41,19 @@ public sealed record ImageMarkup( string? Description = null, int? Width = null, int? Height = null, - ImageAlign? Align = null) : IMarkup; + ImageAlign? Align = null) : IMarkup +{ + /// + /// The row of a laid-out picture this covers, or null for a picture in a line of text. A figure sets it; + /// see . + /// + public PictureRow? Row { get; init; } + + /// + /// Whether the run describes is where a format that writes the picture as an + /// element of its own writes it: the start of an inline picture, or of row 0 of a laid-out one. Every + /// other run of the picture writes no element, so a description in several runs, or art over several + /// rows, is still one picture. + /// + public bool StartsPicture(in EmitContext context) => Row is not { Row: > 0 } && context.StartsRegion(this); +} diff --git a/MarkupString/Elements/PictureCellsMarkup.cs b/MarkupString/Elements/PictureCellsMarkup.cs deleted file mode 100644 index 64021f3..0000000 --- a/MarkupString/Elements/PictureCellsMarkup.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace MarkupString; - -/// -/// One row of the cells a picture covers in a laid-out block, over the text a client that draws no -/// pictures shows there instead: a row of the figure's text art, or blanks. -/// lays one out per row for a reader whose client draws pictures (). -/// -/// -/// The text under it is exactly cells wide, so the block around it lines up -/// whether the picture is drawn or not. A terminal emitter that can draw the picture writes it into -/// those cells in place of the text; every other emitter, and every string operation, sees the text. -/// Only a terminal format draws it, and only when its host gave it the picture's pixels. There is -/// no codec: it exists between a reader's relayout and the render, and is never stored. -/// -/// The picture. -/// Which row of the picture this is, from 0. -/// How many rows the picture covers. -/// How many cells wide the picture is. -public sealed record PictureCellsMarkup(ImageMarkup Image, int Row, int Rows, int Columns) : IMarkup; diff --git a/MarkupString/Elements/PictureRow.cs b/MarkupString/Elements/PictureRow.cs new file mode 100644 index 0000000..aa1582f --- /dev/null +++ b/MarkupString/Elements/PictureRow.cs @@ -0,0 +1,17 @@ +namespace MarkupString; + +/// +/// Which row of a laid-out picture a run is, and how big the picture is in cells. A +/// sets it () on every row it draws its picture in, over the text a client that +/// shows no picture reads there: a row of the figure's text art, its description, or blanks. +/// +/// +/// The text under a row is exactly cells wide, so the block around the picture lines +/// up whichever way a format writes it: a terminal that draws pictures in its cells draws the row there, a +/// format with a picture element of its own writes it once, on row 0, and keeps the cells blank, and every +/// other format writes the text. +/// +/// Which row of the picture this is, from 0. +/// How many rows the picture covers. +/// How many cells wide the picture is. +public readonly record struct PictureRow(int Row, int Rows, int Columns); diff --git a/MarkupString/Layout/Blocks/Block.cs b/MarkupString/Layout/Blocks/Block.cs index b4f384d..967b0c2 100644 --- a/MarkupString/Layout/Blocks/Block.cs +++ b/MarkupString/Layout/Blocks/Block.cs @@ -86,7 +86,7 @@ public sealed record LayoutContext /// For a reader whose client draws pictures in its cells, the cells a picture would take at most /// the given number of columns, or null for one it will not be drawing; null for a reader whose /// client draws none. A the answer is not null for reserves those cells, marked - /// with , and keeps its text art in them for a client the picture + /// with the picture (), and keeps its text art in them for a client the picture /// does not reach after all. /// public Func? Pictures { get; init; } diff --git a/MarkupString/Layout/Blocks/Figure.cs b/MarkupString/Layout/Blocks/Figure.cs index 9fff88e..fff8d37 100644 --- a/MarkupString/Layout/Blocks/Figure.cs +++ b/MarkupString/Layout/Blocks/Figure.cs @@ -25,13 +25,21 @@ public override void Draw(LayoutContext context, int width, IList li ? null : Art.Split("\n").Select(line => line.Text.EndsWith('\r') ? line.Substring(0, line.Length - 1) : line).ToArray(); - if (context.Pictures is { } pictures - && pictures(Image, Math.Min(width, art?.Max(line => line.DisplayWidth) ?? width)) is { Columns: > 0, Rows: > 0 } cells) - art = PictureRows(art, cells, width); + // The picture is on its rows whoever reads them: a terminal that has its pixels draws it in their cells, + // MXP or Pueblo writes it once, and everything else reads what the rows hold. + if (Image.Source.Length > 0) + { + if (context.Pictures is { } pictures + && pictures(Image, Math.Min(width, art?.Max(line => line.DisplayWidth) ?? width)) is { Columns: > 0, Rows: > 0 } cells) + art = PictureRows(art, cells, width); + else if (art is not null) + art = PictureRows(art, default, width); + } if (art is null) { - lines.AddRange(MarkupText.Plain($"[{Description}]").FormatColumn(BlockText.Column(width, context.TextAlignment))); + var described = MarkupText.Plain($"[{Description}]").FormatColumn(BlockText.Column(width, context.TextAlignment)); + lines.AddRange(Image.Source.Length > 0 ? Marked(described, described.Max(line => line.DisplayWidth)) : described); if (Beside is { } after) context.Draw(after, width, lines); return; } @@ -84,8 +92,8 @@ public override void Draw(LayoutContext context, int width, IList li } /// - /// The rows of cells the picture is drawn in, each marked with over - /// what a client the picture does not reach shows there: the art, which keeps its own size, or, with no + /// The rows of cells the picture is drawn in, each marked with the picture and its + /// over what a client the picture does not reach shows there: the art, which keeps its own size, or, with no /// art, blank cells of with the description on the middle row. /// private MarkupText[]? PictureRows(MarkupText[]? art, PictureCells cells, int width) @@ -99,9 +107,16 @@ public override void Draw(LayoutContext context, int width, IList li ? BlockText.Fit(MarkupText.Plain($"[{Description}]"), columns) : BlockText.Blank(columns)).ToArray(); - for (var row = 0; row < rows; row++) - under[row] = MarkupText.Wrap(new PictureCellsMarkup(Image, row, rows, columns), under[row]); - return under; + return Marked(under, columns); + } + + /// , each cells wide, as the rows of the picture. + private MarkupText[] Marked(MarkupText[] rows, int columns) + { + var marked = new MarkupText[rows.Length]; + for (var row = 0; row < rows.Length; row++) + marked[row] = MarkupText.Wrap(Image with { Row = new PictureRow(row, rows.Length, columns) }, rows[row]); + return marked; } /// diff --git a/MarkupString/PublicAPI.Unshipped.txt b/MarkupString/PublicAPI.Unshipped.txt index 2be5a3e..bcf1833 100644 --- a/MarkupString/PublicAPI.Unshipped.txt +++ b/MarkupString/PublicAPI.Unshipped.txt @@ -10,27 +10,28 @@ MarkupString.Layout.PictureCells.PictureCells() -> void MarkupString.Layout.PictureCells.PictureCells(int Columns, int Rows) -> void MarkupString.Layout.PictureCells.Rows.get -> int MarkupString.Layout.PictureCells.Rows.init -> void -MarkupString.PictureCellsMarkup -MarkupString.PictureCellsMarkup.$() -> MarkupString.PictureCellsMarkup! -MarkupString.PictureCellsMarkup.Columns.get -> int -MarkupString.PictureCellsMarkup.Columns.init -> void -MarkupString.PictureCellsMarkup.Deconstruct(out MarkupString.ImageMarkup! Image, out int Row, out int Rows, out int Columns) -> void -MarkupString.PictureCellsMarkup.Equals(MarkupString.PictureCellsMarkup? other) -> bool -MarkupString.PictureCellsMarkup.Image.get -> MarkupString.ImageMarkup! -MarkupString.PictureCellsMarkup.Image.init -> void -MarkupString.PictureCellsMarkup.PictureCellsMarkup(MarkupString.ImageMarkup! Image, int Row, int Rows, int Columns) -> void -MarkupString.PictureCellsMarkup.Row.get -> int -MarkupString.PictureCellsMarkup.Row.init -> void -MarkupString.PictureCellsMarkup.Rows.get -> int -MarkupString.PictureCellsMarkup.Rows.init -> void override MarkupString.Layout.PictureCells.GetHashCode() -> int -override MarkupString.PictureCellsMarkup.Equals(object? obj) -> bool -override MarkupString.PictureCellsMarkup.GetHashCode() -> int -override MarkupString.PictureCellsMarkup.ToString() -> string! static MarkupString.Layout.PictureCells.Fit(int width, int height, int columns, int cellWidth = 10, int cellHeight = 20) -> MarkupString.Layout.PictureCells static MarkupString.Layout.PictureCells.operator !=(MarkupString.Layout.PictureCells left, MarkupString.Layout.PictureCells right) -> bool static MarkupString.Layout.PictureCells.operator ==(MarkupString.Layout.PictureCells left, MarkupString.Layout.PictureCells right) -> bool -static MarkupString.PictureCellsMarkup.operator !=(MarkupString.PictureCellsMarkup? left, MarkupString.PictureCellsMarkup? right) -> bool -static MarkupString.PictureCellsMarkup.operator ==(MarkupString.PictureCellsMarkup? left, MarkupString.PictureCellsMarkup? right) -> bool ~override MarkupString.Layout.PictureCells.Equals(object obj) -> bool ~override MarkupString.Layout.PictureCells.ToString() -> string +MarkupString.ImageMarkup.Row.get -> MarkupString.PictureRow? +MarkupString.ImageMarkup.Row.init -> void +MarkupString.ImageMarkup.StartsPicture(in MarkupString.EmitContext context) -> bool +MarkupString.PictureRow +MarkupString.PictureRow.Columns.get -> int +MarkupString.PictureRow.Columns.init -> void +MarkupString.PictureRow.Deconstruct(out int Row, out int Rows, out int Columns) -> void +MarkupString.PictureRow.Equals(MarkupString.PictureRow other) -> bool +MarkupString.PictureRow.PictureRow() -> void +MarkupString.PictureRow.PictureRow(int Row, int Rows, int Columns) -> void +MarkupString.PictureRow.Row.get -> int +MarkupString.PictureRow.Row.init -> void +MarkupString.PictureRow.Rows.get -> int +MarkupString.PictureRow.Rows.init -> void +override MarkupString.PictureRow.GetHashCode() -> int +static MarkupString.PictureRow.operator !=(MarkupString.PictureRow left, MarkupString.PictureRow right) -> bool +static MarkupString.PictureRow.operator ==(MarkupString.PictureRow left, MarkupString.PictureRow right) -> bool +~override MarkupString.PictureRow.Equals(object obj) -> bool +~override MarkupString.PictureRow.ToString() -> string diff --git a/docs/layout.md b/docs/layout.md index 7ab08d8..72aff62 100644 --- a/docs/layout.md +++ b/docs/layout.md @@ -312,7 +312,7 @@ your own copy of its rules, on the page. | `Frame` (`.Bordered(title, border)`) | the frame, its title set into the top edge | `
` and `` | | `Rule` | a line of the border's top edge with the title in it; inside a frame, a divider meeting the sides | a line drawn in CSS | | `Flex` | items side by side at widths shared from their `Sized` bases, stacked when one would fall under its `Min` | a wrapping flex row | -| `Figure` | the text art, with `Beside` flowing round it | an `` floated beside it | +| `Figure` | the text art, with `Beside` flowing round it; MXP and Pueblo write the picture on its first row and keep its cells blank | an `` floated beside it | | `Fields` | labels in one column, values lined up in the next, a long value wrapping under itself | a `
` laid out as a two-column grid | | `Tree` | items under their parents, joined by guide lines | nested `
    ` with the guides drawn in CSS | | `Gauge` | a bar filled to its share of the width, with its figures | a `` | From 7cad66b0990c75ad484aad298b5178313a996c94 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 22:56:04 +0000 Subject: [PATCH 2/2] Treat art of line breaks alone as no art A figure whose art held only line breaks returned its rows unmarked, so no format wrote the picture. It now takes the description fallback, and a terminal with pixels draws in cells sized for the pane rather than none. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_018ztDNVgzxM8V5WKWBRs2EW --- MarkupString.Tests/Layout/FigurePictureTests.cs | 11 +++++++++++ MarkupString/Layout/Blocks/Figure.cs | 3 +++ 2 files changed, 14 insertions(+) diff --git a/MarkupString.Tests/Layout/FigurePictureTests.cs b/MarkupString.Tests/Layout/FigurePictureTests.cs index a039a82..bd28200 100644 --- a/MarkupString.Tests/Layout/FigurePictureTests.cs +++ b/MarkupString.Tests/Layout/FigurePictureTests.cs @@ -53,6 +53,17 @@ public async Task AFigureWithNoArtIsItsPictureNotItsDescription() await Assert.That(laid.ToPlainText().TrimEnd()).IsEqualTo("[A cat]"); } + [Test] + public async Task ArtOfLineBreaksAloneIsNoArt() + { + var laid = BlockLayout.Build(new Figure(Cat, MarkupText.Plain("\n\r\n")), 20); + + var mxp = laid.Render(MarkupFormat.Mxp, Registry); + + await Assert.That(mxp.TrimEnd()).IsEqualTo(""); + await Assert.That(laid.ToPlainText().TrimEnd()).IsEqualTo("[A cat]"); + } + /// The words beside a floated picture stay in the column they are in for a reader of the art. [Test] public async Task TextBesideAFloatedPictureStaysWhereItWas() diff --git a/MarkupString/Layout/Blocks/Figure.cs b/MarkupString/Layout/Blocks/Figure.cs index fff8d37..9580ad1 100644 --- a/MarkupString/Layout/Blocks/Figure.cs +++ b/MarkupString/Layout/Blocks/Figure.cs @@ -25,6 +25,9 @@ public override void Draw(LayoutContext context, int width, IList li ? null : Art.Split("\n").Select(line => line.Text.EndsWith('\r') ? line.Substring(0, line.Length - 1) : line).ToArray(); + // Art of line breaks alone has no cells to hold a picture, so it is no art. + if (art is not null && art.Max(line => line.DisplayWidth) == 0) art = null; + // The picture is on its rows whoever reads them: a terminal that has its pixels draws it in their cells, // MXP or Pueblo writes it once, and everything else reads what the rows hold. if (Image.Source.Length > 0)