Skip to content

Commit d7f62ea

Browse files
A picture is one ImageMarkup, laid out or inline (#49)
* 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 <img>, 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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ztDNVgzxM8V5WKWBRs2EW * 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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ztDNVgzxM8V5WKWBRs2EW --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent df4710e commit d7f62ea

19 files changed

Lines changed: 394 additions & 116 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
1818
advertising MTTS's MSLP bit sends back when clicked. A command holding a control character is
1919
written as its text.
2020
- **Pictures in a figure's cells.** A `Figure` laid out under `LayoutContext.Pictures` reserves the
21-
cells its picture takes (its art's, or `PictureCells` for one with none) and marks each row with
22-
`PictureCellsMarkup`. In `MarkupFormat.Ansi` such a row is drawn as the picture for a client with
21+
cells its picture takes (its art's, or `PictureCells` for one with none) and marks each row with its
22+
`ImageMarkup`, `Row` set to a `PictureRow`. In `MarkupFormat.Ansi` such a row is drawn as the picture for a client with
2323
`KittyGraphics` (Unicode placeholders, the picture sent once per connection), `InlineImages`
2424
(iTerm2), `Sixel`, or `BlockArt` (half blocks), and as the art otherwise. The rows are the same
2525
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).
4242
the terminal takes in one sequence (a mebibyte, for iTerm2) is sent in parts where it reads them
4343
(`MultipartFile`), and a moving one is sent still where it does not.
4444

45+
### Changed
46+
47+
- **A picture is one `ImageMarkup`, laid out or inline.** `PictureCellsMarkup` is gone: a figure's rows
48+
carry its `ImageMarkup` with `Row` (a `PictureRow`: which row, how many, how many cells wide), and the
49+
serialiser keeps it. MXP, Pueblo, HTML and BBCode write a picture's element once
50+
(`ImageMarkup.StartsPicture`): at the start of an inline picture, however many runs its description is
51+
in, or on a figure's first row.
52+
4553
### Fixed
4654

55+
- **A figure's picture reaches MXP, Pueblo and BBCode.** A `Figure` laid out as text marked its picture
56+
only for a terminal that draws pictures in its cells, so every other format wrote the art alone and an
57+
MXP client was never sent `<IMAGE>`. Its rows (or its `[description]`) now always carry the picture
58+
when it has an address: a format with a picture element writes it on the first row and keeps the
59+
figure's cells blank, so text beside it stays in its column, and a client that refuses the element, or
60+
an HTML policy that refuses `<img>`, reads the art. A terminal without the picture's pixels still writes
61+
the art.
4762
- **A box round a picture fits it in HTML.** The `<img>` of a `Figure` sat on the text baseline,
4863
leaving a band the height of a descender below it, and the box's side padding left gaps left and
4964
right but none above. The picture now aligns to the top of its line, and a box whose body is only a

‎MarkupString.Ansi/Emitters/AnsiSetEmitter.cs‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ namespace MarkupString.Ansi;
99
/// </summary>
1010
/// <remarks>
1111
/// Links and pictures are written as the client's <see cref="AnsiOutputOptions.Features"/> allow: a URL link
12-
/// as OSC 8, a command link as MSLP, a row of a picture's cells (<see cref="PictureCellsMarkup"/>) as the
12+
/// as OSC 8, a command link as MSLP, a row of a figure's picture (<see cref="ImageMarkup.Row"/>) as the
1313
/// picture. Without the feature, a link is its text and a picture its text art.
1414
/// </remarks>
1515
/// <param name="options">What the client is sent: its colour depth and what else its terminal can do.</param>
@@ -56,7 +56,7 @@ public bool TryEmit(MarkupSet set, ReadOnlySpan<char> body, in EmitContext conte
5656
{
5757
// The run that starts the row draws all of it; the others in the same row draw nothing, so the row is
5858
// as many cells as the text it stands over.
59-
if (context.StartsRegion(picture.Cells))
59+
if (context.StartsRegion(picture.Image))
6060
TerminalPictureWriter.Write(_pictureMethod, picture.Cells, picture.Pixels, effective, _options, core, output);
6161
}
6262
else
@@ -73,15 +73,16 @@ public bool TryEmit(MarkupSet set, ReadOnlySpan<char> body, in EmitContext conte
7373
}
7474

7575
/// <summary>The picture this run is a row of, when this client draws it and its pixels are to hand.</summary>
76-
private (PictureCellsMarkup Cells, TerminalPicture Pixels)? Picture(MarkupSet set)
76+
private (ImageMarkup Image, PictureRow Cells, TerminalPicture Pixels)? Picture(MarkupSet set)
7777
{
7878
if (_pictureMethod == TerminalFeatures.None) return null;
7979
for (var i = 0; i < set.Count; i++)
8080
{
81-
if (set[i] is not PictureCellsMarkup cells) continue;
81+
if (set[i] is not ImageMarkup { Row: { } cells } image) continue;
82+
// The pixels are looked up by the picture alone: every row of it is the same picture.
8283
return TerminalPictureWriter.CanDraw(_pictureMethod, cells)
83-
&& _options.Pictures!.TryGetPicture(cells.Image, out var pixels)
84-
? (cells, pixels)
84+
&& _options.Pictures!.TryGetPicture(image with { Row = null }, out var pixels)
85+
? (image, cells, pixels)
8586
: null;
8687
}
8788
return null;

‎MarkupString.Ansi/Emitters/ElementEmitters.cs‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,21 @@ public void Emit(IMarkup markup, ReadOnlySpan<char> body, in EmitContext context
3333
ArgumentNullException.ThrowIfNull(output);
3434

3535
// A bracket would close the tag early, and BBCode has no way to escape one.
36-
var source = ((ImageMarkup)markup).Source;
37-
if (source.AsSpan().IndexOfAny('[', ']') >= 0)
36+
var image = (ImageMarkup)markup;
37+
if (image.Source.AsSpan().IndexOfAny('[', ']') >= 0)
3838
{
3939
output.Write(body);
4040
return;
4141
}
4242

43-
output.Write("[img]");
44-
output.Write(source);
45-
output.Write("[/img]");
43+
if (image.StartsPicture(context))
44+
{
45+
output.Write("[img]");
46+
output.Write(image.Source);
47+
output.Write("[/img]");
48+
}
49+
50+
// A figure's rows keep their cells, so what is beside the picture stays where it was.
51+
if (image.Row is not null) output.Write(new string(' ', DisplayWidth.Of(body)));
4652
}
4753
}

‎MarkupString.Ansi/Emitters/TerminalPictureWriter.cs‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
namespace MarkupString.Ansi;
55

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

6666
/// <summary>Whether <paramref name="cells"/> can be drawn by <paramref name="method"/> at all.</summary>
67-
internal static bool CanDraw(TerminalFeatures method, PictureCellsMarkup cells) =>
67+
internal static bool CanDraw(TerminalFeatures method, PictureRow cells) =>
6868
cells.Columns > 0 && cells.Rows > 0 && cells.Row >= 0 && cells.Row < cells.Rows
6969
&& (method != TerminalFeatures.KittyGraphics || (cells.Columns <= Diacritics.Length && cells.Rows <= Diacritics.Length));
7070

@@ -84,7 +84,7 @@ internal static bool CanDraw(TerminalFeatures method, PictureCellsMarkup cells)
8484
/// </param>
8585
internal static void Write(
8686
TerminalFeatures method,
87-
PictureCellsMarkup cells,
87+
PictureRow cells,
8888
TerminalPicture picture,
8989
in AnsiStyle effective,
9090
AnsiOutputOptions options,
@@ -111,7 +111,7 @@ internal static void Write(
111111
/// its size in cells, since each size is its own virtual placement. 24 bits, so it fits a truecolor
112112
/// foreground; never zero, which Kitty reads as no id.
113113
/// </summary>
114-
internal static uint KittyId(TerminalPicture picture, PictureCellsMarkup cells)
114+
internal static uint KittyId(TerminalPicture picture, PictureRow cells)
115115
{
116116
var hash = 2166136261u;
117117
foreach (var c in picture.Key) hash = (hash ^ c) * 16777619u;
@@ -121,7 +121,7 @@ internal static uint KittyId(TerminalPicture picture, PictureCellsMarkup cells)
121121
return id == 0 ? 1 : id;
122122
}
123123

124-
private static void WriteKitty(PictureCellsMarkup cells, TerminalPicture picture, in AnsiStyle effective, AnsiOutputOptions options,
124+
private static void WriteKitty(PictureRow cells, TerminalPicture picture, in AnsiStyle effective, AnsiOutputOptions options,
125125
IBufferWriter<char> output, IBufferWriter<char> ahead)
126126
{
127127
var id = KittyId(picture, cells);
@@ -243,7 +243,7 @@ private static void AppendKittyChunks(StringBuilder text, ReadOnlySpan<byte> rgb
243243
/// down past the picture), comes back up, and draws the picture between a cursor save and restore. Every row
244244
/// then steps over the picture's cells (<c>CSI n C</c>) rather than writing spaces that would erase it.
245245
/// </summary>
246-
private static void WriteOverlay(TerminalFeatures method, PictureCellsMarkup cells, TerminalPicture picture, AnsiOutputOptions options,
246+
private static void WriteOverlay(TerminalFeatures method, PictureRow cells, TerminalPicture picture, AnsiOutputOptions options,
247247
IBufferWriter<char> output, IBufferWriter<char> ahead)
248248
{
249249
if (cells.Row == 0)
@@ -330,7 +330,7 @@ private static string EncodeSixel(TerminalPicture picture, OverlayKey key)
330330
/// foreground of <c>▀</c>, the lower its background. A transparent half shows the run's own background.
331331
/// Every row of the picture is made at once and kept, since the rows are drawn one after another.
332332
/// </summary>
333-
private static void WriteBlockArt(PictureCellsMarkup cells, TerminalPicture picture, in AnsiStyle effective, AnsiColorDepth depth, IBufferWriter<char> output)
333+
private static void WriteBlockArt(PictureRow cells, TerminalPicture picture, in AnsiStyle effective, AnsiColorDepth depth, IBufferWriter<char> output)
334334
{
335335
var rows = PictureEncodings.GetOrAdd(picture, new BlockKey(cells.Columns, cells.Rows, depth, effective), EncodeBlockArt);
336336
output.Write(rows[cells.Row]);

‎MarkupString.Ansi/README.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,9 @@ fully. `TerminalFeatures` says what its terminal reads beyond colour:
7373
| `MovingPictures` | a moving picture played, through Kitty frames or an iTerm2 GIF, rather than its first frame |
7474

7575
Pictures are drawn into the cells a `Figure` reserves when it is laid out for such a reader
76-
(`LayoutContext.Pictures`, which answers the cells a picture takes). Each row is marked with
77-
`PictureCellsMarkup` over the figure's text art, so a box, a flex row or a table around it lines up
78-
whichever way it ends up drawn. The pixels come from the host's `ITerminalPictureSource`: this package
76+
(`LayoutContext.Pictures`, which answers the cells a picture takes). Each row is marked with the
77+
figure's `ImageMarkup`, its `Row` saying which row of the picture it is, over the figure's text art, so a
78+
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
7979
neither fetches nor decodes a file. Without the pixels, or without the feature, the row is its art.
8080

8181
- **Kitty** sends the picture once per connection (`a=T,U=1`, a PNG in 4096-byte chunks, `q=2` so

‎MarkupString.Html/Emitters/ElementHtmlEmitter.cs‎

Lines changed: 41 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -85,14 +85,30 @@ public void Emit(IMarkup markup, ReadOnlySpan<char> body, in EmitContext context
8585
return;
8686

8787
case ImageMarkup image:
88-
Write(markup, output, "img", Shape.Void, body, context,
89-
("class", "ms-image"),
90-
("src", image.Source),
91-
("alt", image.Description ?? string.Empty),
92-
("width", Number(image.Width)),
93-
("height", Number(image.Height)),
94-
("data-align", image.Align?.ToString().ToLowerInvariant()));
95-
return;
88+
{
89+
ReadOnlySpan<(string, string?)> attributes =
90+
[
91+
("class", "ms-image"),
92+
("src", image.Source),
93+
("alt", image.Description ?? string.Empty),
94+
("width", Number(image.Width)),
95+
("height", Number(image.Height)),
96+
("data-align", image.Align?.ToString().ToLowerInvariant()),
97+
];
98+
99+
// A picture the policy refuses is its text on every run, the rows of a figure's art included.
100+
if (Held("img", attributes) is null)
101+
{
102+
output.Write(body);
103+
return;
104+
}
105+
106+
if (image.StartsPicture(context)) Write(markup, output, "img", Shape.Void, body, context, attributes);
107+
108+
// A figure's rows keep their cells, so what is beside the picture stays where it was.
109+
if (image.Row is not null) output.Write(new string(' ', DisplayWidth.Of(body)));
110+
return;
111+
}
96112

97113
case PreformattedMarkup:
98114
Write(markup, output, "pre", Shape.Wrapping, body, context, ("class", "ms-preformatted"));
@@ -140,23 +156,10 @@ private void Write(
140156
in EmitContext context,
141157
params ReadOnlySpan<(string Name, string? Value)> attributes)
142158
{
143-
var written = new HtmlAttribute[Count(attributes)];
144-
var next = 0;
145-
foreach (var (attribute, value) in attributes)
159+
if (Held(name, attributes) is not { } tag)
146160
{
147-
if (value is not null) written[next++] = new HtmlAttribute(attribute, value);
148-
}
149-
150-
var tag = HtmlMarkup.Tag(name, written);
151-
if (policy is not null)
152-
{
153-
if (policy.Apply(tag) is not { } held)
154-
{
155-
if (markup is not IPointMarkup) output.Write(body);
156-
return;
157-
}
158-
159-
tag = held;
161+
if (markup is not IPointMarkup) output.Write(body);
162+
return;
160163
}
161164

162165
// One element around the whole stretch this layer covers, however many runs its content is in.
@@ -195,6 +198,20 @@ private void Write(
195198
output.Write(">");
196199
}
197200

201+
/// <summary>The element as the policy lets it be written, or null for one it refuses.</summary>
202+
private HtmlMarkup? Held(string name, ReadOnlySpan<(string Name, string? Value)> attributes)
203+
{
204+
var written = new HtmlAttribute[Count(attributes)];
205+
var next = 0;
206+
foreach (var (attribute, value) in attributes)
207+
{
208+
if (value is not null) written[next++] = new HtmlAttribute(attribute, value);
209+
}
210+
211+
var tag = HtmlMarkup.Tag(name, written);
212+
return policy is null ? tag : policy.Apply(tag);
213+
}
214+
198215
private static int Count(ReadOnlySpan<(string Name, string? Value)> attributes)
199216
{
200217
var count = 0;

‎MarkupString.Mxp/Emitters/ElementMxpEmitter.cs‎

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,13 +60,19 @@ public void Emit(IMarkup markup, ReadOnlySpan<char> body, in EmitContext context
6060
return;
6161
}
6262

63-
var (file, directory) = Split(image.Source);
64-
tag.Open("IMAGE").Positional(file)
65-
.Named("URL", directory)
66-
.Named("W", image.Width)
67-
.Named("H", image.Height)
68-
.Named("ALIGN", image.Align?.ToString().ToUpperInvariant())
69-
.Close();
63+
if (image.StartsPicture(context))
64+
{
65+
var (file, directory) = Split(image.Source);
66+
tag.Open("IMAGE").Positional(file)
67+
.Named("URL", directory)
68+
.Named("W", image.Width)
69+
.Named("H", image.Height)
70+
.Named("ALIGN", image.Align?.ToString().ToUpperInvariant())
71+
.Close();
72+
}
73+
74+
// A figure's rows keep their cells, so what is beside the picture stays where it was.
75+
if (image.Row is not null) output.Write(new string(' ', DisplayWidth.Of(body)));
7076
return;
7177
}
7278

‎MarkupString.Mxp/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ written. Asking belongs to the telnet layer, and the answer to one connection.
5656
| `MarkupText.Music(source, volume, repeats, continues)` | `<MUSIC file V= L= C=1 U=>` |
5757
| `MarkupText.StopSound(channel)` | `<SOUND Off>`, `<MUSIC Off>` |
5858
| `MarkupText.Image(source, description, width, height, align)` | `<IMAGE file URL= W= H= ALIGN=>` |
59+
| a laid-out `Figure` | `<IMAGE file URL=>` on its first row, its other cells blank |
5960
| `MarkupText.Pane(content, name, title)` | `<FRAME name TITLE=><DEST name>content</DEST>` |
6061
| `MarkupText.ExpireLinks(group)` | `<EXPIRE group>` |
6162
| `MarkupText.Relocate(host, port, quiet)` | `<RELOCATE host port QUIET>` |

‎MarkupString.Pueblo/Emitters/ElementPuebloEmitter.cs‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,18 @@ public void Emit(IMarkup markup, ReadOnlySpan<char> body, in EmitContext context
6363
return;
6464

6565
case ImageMarkup image:
66-
Tag(output, "img",
67-
("src", image.Source),
68-
("alt", image.Description ?? string.Empty),
69-
("width", image.Width?.ToString(CultureInfo.InvariantCulture)),
70-
("height", image.Height?.ToString(CultureInfo.InvariantCulture)),
71-
("align", image.Align?.ToString().ToLowerInvariant()));
66+
if (image.StartsPicture(context))
67+
{
68+
Tag(output, "img",
69+
("src", image.Source),
70+
("alt", image.Description ?? string.Empty),
71+
("width", image.Width?.ToString(CultureInfo.InvariantCulture)),
72+
("height", image.Height?.ToString(CultureInfo.InvariantCulture)),
73+
("align", image.Align?.ToString().ToLowerInvariant()));
74+
}
75+
76+
// A figure's rows keep their cells, so what is beside the picture stays where it was.
77+
if (image.Row is not null) output.Write(new string(' ', DisplayWidth.Of(body)));
7278
return;
7379

7480
case PreformattedMarkup:

‎MarkupString.Tests/Ansi/TerminalFeatureTests.cs‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ public async Task AFigureForAReaderWithPicturesMarksEachRowOfItsArt()
104104
var laid = Laid(figure, 20);
105105

106106
await Assert.That(laid.ToPlainText()).IsEqualTo(BlockLayout.Build(figure, 20).ToPlainText());
107-
var rows = laid.Runs.SelectMany(run => run.Markups).OfType<PictureCellsMarkup>().Distinct().ToArray();
107+
var rows = laid.Runs.SelectMany(run => run.Markups).OfType<ImageMarkup>().Select(image => image.Row!.Value).Distinct().ToArray();
108108
await Assert.That(rows.Select(r => (r.Row, r.Rows, r.Columns))).IsEquivalentTo(new[] { (0, 2, 7), (1, 2, 7) });
109109
}
110110

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

121+
/// <summary>
122+
/// A reader who gets no picture still has it marked on the art's rows, for a format that writes it as an
123+
/// element of its own; the cells are the art's and the text is the art.
124+
/// </summary>
121125
[Test]
122126
public async Task AFigureIsItsArtWhenTheReaderGetsNoPicture()
123127
{
124128
var figure = new Figure(Cat, MarkupText.Plain("=^.^="));
125129

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

128-
await Assert.That(laid.Runs.SelectMany(run => run.Markups).OfType<PictureCellsMarkup>()).IsEmpty();
132+
await Assert.That(laid.ToPlainText().TrimEnd()).IsEqualTo("=^.^=");
133+
await Assert.That(laid.Runs.SelectMany(run => run.Markups).OfType<ImageMarkup>().Select(image => image.Row))
134+
.IsEquivalentTo(new PictureRow?[] { new PictureRow(0, 1, 5) });
135+
await Assert.That(RenderString(laid, new AnsiOutputOptions(Features: TerminalFeatures.KittyGraphics) { Pictures = new Source(null) }).TrimEnd())
136+
.IsEqualTo("=^.^=");
129137
}
130138

131139
[Test]

0 commit comments

Comments
 (0)