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..bd28200 --- /dev/null +++ b/MarkupString.Tests/Layout/FigurePictureTests.cs @@ -0,0 +1,172 @@ +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]"); + } + + [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() + { + 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..9580ad1 100644 --- a/MarkupString/Layout/Blocks/Figure.cs +++ b/MarkupString/Layout/Blocks/Figure.cs @@ -25,13 +25,24 @@ 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); + // 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) + { + 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 +95,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 +110,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 `` |