Skip to content

Commit ba0ce27

Browse files
Size a figure's MXP picture in the cells laid out for it (#52)
A figure's <IMAGE> carried no size, so a client drew the picture at its own size under the line, outside any box round the figure. The tag now says how many cells the picture covers (W=12c H=5c), the art's or the PictureCells a host gave it, so a client can draw it there. A row that is only the picture's description is marked PictureRow.IsDescription and gets no cell size: a picture squeezed into one line of its description would be a strip. Claude-Session: https://claude.ai/code/session_018ztDNVgzxM8V5WKWBRs2EW Co-authored-by: Claude <noreply@anthropic.com>
1 parent 62345a3 commit ba0ce27

8 files changed

Lines changed: 71 additions & 13 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5252

5353
### Fixed
5454

55+
- **An MXP client draws a figure's picture in its cells.** A figure's `<IMAGE>` carried no size, so a
56+
client drew the picture at its own size under the line it was named on, outside any box round it. It
57+
is now sized in the cells laid out for it (`W=12c H=5c`), the art's or `PictureCells`', and a client
58+
that honours cell sizes draws it there. A figure with neither, whose row is only its description
59+
(`PictureRow.IsDescription`), still leaves the picture its own size.
5560
- **A figure whose art is the picture's own placeholder is one picture.** Art marked as the picture
5661
inline (a Markdown image laid out as a figure) carried both marks, so MXP, Pueblo and BBCode wrote the
5762
picture twice or dropped its cells, and the text beside it moved. A figure's rows now carry its mark

‎MarkupString.Mxp/Emitters/ElementMxpEmitter.cs‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,15 @@ public void Emit(IMarkup markup, ReadOnlySpan<char> body, in EmitContext context
6363
if (image.StartsPicture(context))
6464
{
6565
var (file, directory) = Split(image.Source);
66+
// A figure's picture is sized in the cells laid out for it, so a client draws it over
67+
// them rather than at its own size beside or under them.
68+
var (width, height) = image.Row is { IsDescription: false } row
69+
? ($"{row.Columns}c", $"{row.Rows}c")
70+
: (Pixels(image.Width), Pixels(image.Height));
6671
tag.Open("IMAGE").Positional(file)
6772
.Named("URL", directory)
68-
.Named("W", image.Width)
69-
.Named("H", image.Height)
73+
.Named("W", width)
74+
.Named("H", height)
7075
.Named("ALIGN", image.Align?.ToString().ToUpperInvariant())
7176
.Close();
7277
}
@@ -161,6 +166,8 @@ public void Emit(IMarkup markup, ReadOnlySpan<char> body, in EmitContext context
161166

162167
private bool Supports(string element) => supports?.Invoke(element) ?? true;
163168

169+
private static string? Pixels(int? pixels) => pixels?.ToString(CultureInfo.InvariantCulture);
170+
164171
/// <summary>
165172
/// MXP names a file and, separately, the address of the directory to fetch it from when the client
166173
/// does not have it. An absolute address is split at its last slash; anything else is a file name.

‎MarkupString.Tests/Layout/FigurePictureTests.cs‎

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ public async Task MxpWritesTheFiguresPictureOnceAndKeepsItsCells()
3636

3737
var lines = Lines(laid, MarkupFormat.Mxp);
3838

39-
await Assert.That(lines[0].TrimEnd()).IsEqualTo("<IMAGE cat.png URL=https://example.test/img/>");
39+
// Sized in the cells the art takes, so a client draws it over them.
40+
await Assert.That(lines[0].TrimEnd()).IsEqualTo("<IMAGE cat.png URL=https://example.test/img/ W=7c H=3c>");
4041
await Assert.That(Count(string.Join('\n', lines), "<IMAGE")).IsEqualTo(1);
4142
await Assert.That(lines.Skip(1).All(line => line.Trim().Length == 0)).IsTrue();
4243
await Assert.That(lines.Length).IsEqualTo(3);
@@ -53,6 +54,21 @@ public async Task AFigureWithNoArtIsItsPictureNotItsDescription()
5354
await Assert.That(laid.ToPlainText().TrimEnd()).IsEqualTo("[A cat]");
5455
}
5556

57+
/// <summary>
58+
/// A picture given cells of its own and no art is sized in them; its description alone is not cells laid
59+
/// out for it, so a figure that has nothing else leaves the picture its own size.
60+
/// </summary>
61+
[Test]
62+
public async Task OnlyCellsLaidOutForThePictureSizeIt()
63+
{
64+
var context = new LayoutContext { Pictures = (_, _) => new PictureCells(12, 5) };
65+
var sized = BlockLayout.Build(new Figure(Cat, MarkupText.Empty), 20, context: context);
66+
var described = BlockLayout.Build(new Figure(Cat, MarkupText.Empty), 20);
67+
68+
await Assert.That(Lines(sized, MarkupFormat.Mxp)[0].TrimEnd()).IsEqualTo("<IMAGE cat.png URL=https://example.test/img/ W=12c H=5c>");
69+
await Assert.That(Lines(described, MarkupFormat.Mxp)[0].TrimEnd()).IsEqualTo("<IMAGE cat.png URL=https://example.test/img/>");
70+
}
71+
5672
[Test]
5773
public async Task ArtOfLineBreaksAloneIsNoArt()
5874
{
@@ -80,7 +96,7 @@ public async Task ArtMarkedAsThePictureIsStillOnePicture(string format)
8096
var markupFormat = format switch { "mxp" => MarkupFormat.Mxp, "pueblo" => MarkupFormat.Pueblo, _ => MarkupFormat.BBCode };
8197
var tag = format switch
8298
{
83-
"mxp" => "<IMAGE cat.png URL=https://example.test/img/>",
99+
"mxp" => "<IMAGE cat.png URL=https://example.test/img/ W=14c H=1c>",
84100
"pueblo" => "<img src=\"https://example.test/img/cat.png\" alt=\"A cat\">",
85101
_ => "[img]https://example.test/img/cat.png[/img]",
86102
};
@@ -102,8 +118,9 @@ public async Task TextBesideAFloatedPictureStaysWhereItWas()
102118
var plain = laid.ToPlainText().Split('\n');
103119
var mxp = Lines(laid, MarkupFormat.Mxp);
104120

105-
await Assert.That(mxp[0]).StartsWith("<IMAGE cat.png URL=https://example.test/img/>");
106-
var untagged = mxp.Select(line => line.Replace("<IMAGE cat.png URL=https://example.test/img/>", string.Empty)).ToArray();
121+
const string tag = "<IMAGE cat.png URL=https://example.test/img/ W=7c H=3c>";
122+
await Assert.That(mxp[0]).StartsWith(tag);
123+
var untagged = mxp.Select(line => line.Replace(tag, string.Empty)).ToArray();
107124
await Assert.That(untagged.Length).IsEqualTo(plain.Length);
108125
// The art is seven cells and the gap two: the picture's cells are blank, and the words start where they did.
109126
for (var row = 0; row < plain.Length; row++)
@@ -174,6 +191,18 @@ public async Task HtmlHeldToAPolicyThatRefusesThePictureIsTheArt()
174191
await Assert.That(second.Render(MarkupFormat.Html, registry)).IsEqualTo("( o.o )");
175192
}
176193

194+
[Test]
195+
public async Task ADescriptionRowSurvivesSerialisation()
196+
{
197+
var laid = BlockLayout.Build(new Figure(Cat, MarkupText.Empty), 20);
198+
199+
var back = MarkupTextSerializer.Deserialize(MarkupTextSerializer.Serialize(laid, Registry), Registry);
200+
201+
await Assert.That(back.Runs.SelectMany(run => run.Markups).OfType<ImageMarkup>().All(image => image.Row is { IsDescription: true }))
202+
.IsTrue();
203+
await Assert.That(back.Render(MarkupFormat.Mxp, Registry)).IsEqualTo(laid.Render(MarkupFormat.Mxp, Registry));
204+
}
205+
177206
[Test]
178207
public async Task ARowOfAPictureSurvivesSerialisation()
179208
{

‎MarkupString/Elements/ElementCodecs.cs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ internal static class ElementCodecs
4949
w.WriteNumber("r", row.Row);
5050
w.WriteNumber("rs", row.Rows);
5151
w.WriteNumber("c", row.Columns);
52+
if (row.IsDescription) w.WriteBoolean("dsc", true);
5253
}
5354
},
5455
static e => new ImageMarkup(
@@ -59,7 +60,7 @@ internal static class ElementCodecs
5960
Enum.TryParse<ImageAlign>(String(e, "a"), ignoreCase: true, out var align) ? align : null)
6061
{
6162
Row = Int(e, "r") is { } row && Int(e, "rs") is { } rows && Int(e, "c") is { } columns
62-
? new PictureRow(row, rows, columns)
63+
? new PictureRow(row, rows, columns) { IsDescription = e.TryGetProperty("dsc", out var described) && described.ValueKind == JsonValueKind.True }
6364
: null,
6465
}),
6566
new Codec<PaneMarkup>("pane",

‎MarkupString/Elements/ImageMarkup.cs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ public enum ImageAlign
2626
/// </summary>
2727
/// <remarks>
2828
/// <para>MXP writes <c>&lt;IMAGE&gt;</c>, Pueblo and HTML <c>&lt;img&gt;</c>, and BBCode <c>[img]</c>, once
29-
/// for the picture (<see cref="StartsPicture"/>); over a figure's rows they keep the rest of its cells blank.
29+
/// for the picture (<see cref="StartsPicture"/>); over a figure's rows they keep the rest of its cells blank,
30+
/// and MXP sizes the tag to those cells (<c>W=12c H=5c</c>) so a client draws the picture in them.
3031
/// A terminal draws a figure's rows in its cells when its host has the picture's pixels. Every other format,
3132
/// and a client that refuses the picture, writes the wrapped text, so a terminal still learns there was a
3233
/// picture and where it is. Put it inside a link to make it one.</para>

‎MarkupString/Elements/PictureRow.cs‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,13 @@ namespace MarkupString;
1414
/// <param name="Row">Which row of the picture this is, from 0.</param>
1515
/// <param name="Rows">How many rows the picture covers.</param>
1616
/// <param name="Columns">How many cells wide the picture is.</param>
17-
public readonly record struct PictureRow(int Row, int Rows, int Columns);
17+
public readonly record struct PictureRow(int Row, int Rows, int Columns)
18+
{
19+
/// <summary>
20+
/// Whether these cells hold the picture's description rather than cells laid out for it: the figure had no
21+
/// art, and nothing gave the picture a size in cells. A format that sizes its picture element to the cells
22+
/// it covers (MXP's <c>W</c> and <c>H</c>) leaves these alone, since a picture drawn in a line of its
23+
/// description would be a strip.
24+
/// </summary>
25+
public bool IsDescription { get; init; }
26+
}

‎MarkupString/Layout/Blocks/Figure.cs‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ public override void Draw(LayoutContext context, int width, IList<MarkupText> li
3838
if (art is null)
3939
{
4040
var described = MarkupText.Plain($"[{Description}]").FormatColumn(BlockText.Column(width, context.TextAlignment));
41-
lines.AddRange(Image.Source.Length > 0 ? Marked(described, described.Max(line => line.DisplayWidth)) : described);
41+
lines.AddRange(Image.Source.Length > 0 ? Marked(described, described.Max(line => line.DisplayWidth), description: true) : described);
4242
if (Beside is { } after) context.Draw(after, width, lines);
4343
return;
4444
}
@@ -130,12 +130,16 @@ public override void Draw(LayoutContext context, int width, IList<MarkupText> li
130130
return Marked(under, columns);
131131
}
132132

133-
/// <summary><paramref name="rows"/>, each <paramref name="columns"/> cells wide, as the rows of the picture.</summary>
134-
private MarkupText[] Marked(MarkupText[] rows, int columns)
133+
/// <summary>
134+
/// <paramref name="rows"/>, each <paramref name="columns"/> cells wide, as the rows of the picture: its
135+
/// <paramref name="description"/> standing in for it, or cells laid out for it.
136+
/// </summary>
137+
private MarkupText[] Marked(MarkupText[] rows, int columns, bool description = false)
135138
{
136139
var marked = new MarkupText[rows.Length];
137140
for (var row = 0; row < rows.Length; row++)
138-
marked[row] = MarkupText.Wrap(Image with { Row = new PictureRow(row, rows.Length, columns) }, Unpictured(rows[row]));
141+
marked[row] = MarkupText.Wrap(Image with { Row = new PictureRow(row, rows.Length, columns) { IsDescription = description } },
142+
Unpictured(rows[row]));
139143
return marked;
140144
}
141145

‎MarkupString/PublicAPI.Unshipped.txt‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,5 @@ static MarkupString.PictureRow.operator !=(MarkupString.PictureRow left, MarkupS
3535
static MarkupString.PictureRow.operator ==(MarkupString.PictureRow left, MarkupString.PictureRow right) -> bool
3636
~override MarkupString.PictureRow.Equals(object obj) -> bool
3737
~override MarkupString.PictureRow.ToString() -> string
38+
MarkupString.PictureRow.IsDescription.get -> bool
39+
MarkupString.PictureRow.IsDescription.init -> void

0 commit comments

Comments
 (0)