Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 `<IMAGE>`. 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 `<img>`, reads the art. A terminal without the picture's pixels still writes
the art.
- **A box round a picture fits it in HTML.** The `<img>` 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
Expand Down
13 changes: 7 additions & 6 deletions MarkupString.Ansi/Emitters/AnsiSetEmitter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ namespace MarkupString.Ansi;
/// </summary>
/// <remarks>
/// Links and pictures are written as the client's <see cref="AnsiOutputOptions.Features"/> allow: a URL link
/// as OSC 8, a command link as MSLP, a row of a picture's cells (<see cref="PictureCellsMarkup"/>) as the
/// as OSC 8, a command link as MSLP, a row of a figure's picture (<see cref="ImageMarkup.Row"/>) as the
/// picture. Without the feature, a link is its text and a picture its text art.
/// </remarks>
/// <param name="options">What the client is sent: its colour depth and what else its terminal can do.</param>
Expand Down Expand Up @@ -56,7 +56,7 @@ public bool TryEmit(MarkupSet set, ReadOnlySpan<char> 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
Expand All @@ -73,15 +73,16 @@ public bool TryEmit(MarkupSet set, ReadOnlySpan<char> body, in EmitContext conte
}

/// <summary>The picture this run is a row of, when this client draws it and its pixels are to hand.</summary>
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;
Expand Down
16 changes: 11 additions & 5 deletions MarkupString.Ansi/Emitters/ElementEmitters.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,21 @@ public void Emit(IMarkup markup, ReadOnlySpan<char> 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)));
}
}
14 changes: 7 additions & 7 deletions MarkupString.Ansi/Emitters/TerminalPictureWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
namespace MarkupString.Ansi;

/// <summary>
/// Draws one row of a picture's cells (<see cref="PictureCellsMarkup"/>) in a terminal, in whichever way
/// Draws one row of a figure's picture (<see cref="PictureRow"/>) in a terminal, in whichever way
/// <see cref="AnsiOutputOptions.Features"/> says the client draws pictures.
/// </summary>
/// <remarks>
Expand Down Expand Up @@ -64,7 +64,7 @@ internal static TerminalFeatures Method(TerminalFeatures features) =>
: TerminalFeatures.None;

/// <summary>Whether <paramref name="cells"/> can be drawn by <paramref name="method"/> at all.</summary>
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));

Expand All @@ -84,7 +84,7 @@ internal static bool CanDraw(TerminalFeatures method, PictureCellsMarkup cells)
/// </param>
internal static void Write(
TerminalFeatures method,
PictureCellsMarkup cells,
PictureRow cells,
TerminalPicture picture,
in AnsiStyle effective,
AnsiOutputOptions options,
Expand All @@ -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.
/// </summary>
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;
Expand All @@ -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<char> output, IBufferWriter<char> ahead)
{
var id = KittyId(picture, cells);
Expand Down Expand Up @@ -243,7 +243,7 @@ private static void AppendKittyChunks(StringBuilder text, ReadOnlySpan<byte> 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 (<c>CSI n C</c>) rather than writing spaces that would erase it.
/// </summary>
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<char> output, IBufferWriter<char> ahead)
{
if (cells.Row == 0)
Expand Down Expand Up @@ -330,7 +330,7 @@ private static string EncodeSixel(TerminalPicture picture, OverlayKey key)
/// foreground of <c>▀</c>, 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.
/// </summary>
private static void WriteBlockArt(PictureCellsMarkup cells, TerminalPicture picture, in AnsiStyle effective, AnsiColorDepth depth, IBufferWriter<char> output)
private static void WriteBlockArt(PictureRow cells, TerminalPicture picture, in AnsiStyle effective, AnsiColorDepth depth, IBufferWriter<char> output)
{
var rows = PictureEncodings.GetOrAdd(picture, new BlockKey(cells.Columns, cells.Rows, depth, effective), EncodeBlockArt);
output.Write(rows[cells.Row]);
Expand Down
6 changes: 3 additions & 3 deletions MarkupString.Ansi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 41 additions & 24 deletions MarkupString.Html/Emitters/ElementHtmlEmitter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,14 +85,30 @@ public void Emit(IMarkup markup, ReadOnlySpan<char> 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"));
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -195,6 +198,20 @@ private void Write(
output.Write(">");
}

/// <summary>The element as the policy lets it be written, or null for one it refuses.</summary>
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;
Expand Down
20 changes: 13 additions & 7 deletions MarkupString.Mxp/Emitters/ElementMxpEmitter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,19 @@ public void Emit(IMarkup markup, ReadOnlySpan<char> 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;
}

Expand Down
1 change: 1 addition & 0 deletions MarkupString.Mxp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ written. Asking belongs to the telnet layer, and the answer to one connection.
| `MarkupText.Music(source, volume, repeats, continues)` | `<MUSIC file V= L= C=1 U=>` |
| `MarkupText.StopSound(channel)` | `<SOUND Off>`, `<MUSIC Off>` |
| `MarkupText.Image(source, description, width, height, align)` | `<IMAGE file URL= W= H= ALIGN=>` |
| a laid-out `Figure` | `<IMAGE file URL=>` on its first row, its other cells blank |
| `MarkupText.Pane(content, name, title)` | `<FRAME name TITLE=><DEST name>content</DEST>` |
| `MarkupText.ExpireLinks(group)` | `<EXPIRE group>` |
| `MarkupText.Relocate(host, port, quiet)` | `<RELOCATE host port QUIET>` |
Expand Down
18 changes: 12 additions & 6 deletions MarkupString.Pueblo/Emitters/ElementPuebloEmitter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,18 @@ public void Emit(IMarkup markup, ReadOnlySpan<char> 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:
Expand Down
12 changes: 10 additions & 2 deletions MarkupString.Tests/Ansi/TerminalFeatureTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PictureCellsMarkup>().Distinct().ToArray();
var rows = laid.Runs.SelectMany(run => run.Markups).OfType<ImageMarkup>().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) });
}

Expand All @@ -118,14 +118,22 @@ public async Task AFigureWithNoArtReservesTheCellsItIsGiven()
await Assert.That(lines.Select(l => l.TrimEnd())).IsEquivalentTo(new[] { "", "[A cat]", "" });
}

/// <summary>
/// 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.
/// </summary>
[Test]
public async Task AFigureIsItsArtWhenTheReaderGetsNoPicture()
{
var figure = new Figure(Cat, MarkupText.Plain("=^.^="));

var laid = BlockLayout.Build(figure, 20, context: new LayoutContext { Pictures = (_, _) => null });

await Assert.That(laid.Runs.SelectMany(run => run.Markups).OfType<PictureCellsMarkup>()).IsEmpty();
await Assert.That(laid.ToPlainText().TrimEnd()).IsEqualTo("=^.^=");
await Assert.That(laid.Runs.SelectMany(run => run.Markups).OfType<ImageMarkup>().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]
Expand Down
Loading
Loading