diff --git a/CHANGELOG.md b/CHANGELOG.md
index d487f49..bf4183c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -28,6 +28,14 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
RGB when the picture is opaque, filtered Up, at zlib level 2; sixel reads each band once and writes a
colour only over the columns it reaches.
+### Fixed
+
+- **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
+ picture on a line of its own (`ms-box-picture`) has no padding and is no wider than the picture.
+ A `Frame`'s `Padding` reaches the HTML as `--ms-pad` (it was always `1ch`).
+
## 2.11.2 — 2026-10-07
### Fixed
diff --git a/MarkupString.Html/Emitters/LayoutHtml.cs b/MarkupString.Html/Emitters/LayoutHtml.cs
index 9c3d5ab..fcaecc4 100644
--- a/MarkupString.Html/Emitters/LayoutHtml.cs
+++ b/MarkupString.Html/Emitters/LayoutHtml.cs
@@ -68,7 +68,17 @@ private static void Frame(Frame frame, HtmlLayoutWriter html)
{
html.Write("
");
+ // A box round nothing but a picture fits it: no padding, and no wider than the picture.
+ if (frame.Body is Figure { Float: FigureFloat.None, Beside: null } figure && html.AllowsImage(figure.Image.Source))
+ html.Write(" ms-box-picture");
+ html.Write("\"");
+ if (frame.Padding != 1)
+ {
+ html.Write(" style=\"--ms-pad:");
+ html.Write(Number(Math.Max(0, frame.Padding)));
+ html.Write("ch\"");
+ }
+ html.Write(">");
if (frame.Title is { Length: > 0 } title)
{
html.Write("-default form of each, under what the page sets.
+/// A box's padding is --ms-pad (1ch unset), set on the box when its own differs.
/// Everything is sized in ch , the width of a cell in the terminal font the layout was laid out
/// in, and every row wraps, so nothing scrolls sideways on a narrow page.
///
@@ -20,7 +21,7 @@ public static class LayoutCss
public static readonly string Fixed =
".ms-layout { display: block; max-width: 100%; white-space: normal; }\n" +
".ms-text { white-space: pre-wrap; overflow-wrap: anywhere; }\n" +
- ".ms-box { margin: 0; padding: 0 1ch; min-width: 0; border: 1px solid var(--ms-border, var(--ms-border-default, currentColor)); }\n" +
+ ".ms-box { margin: 0; padding: 0 var(--ms-pad, 1ch); min-width: 0; border: 1px solid var(--ms-border, var(--ms-border-default, currentColor)); }\n" +
".ms-box.ms-border-none { border: none; padding: 0; }\n" +
".ms-box.ms-border-double { border: 3px double var(--ms-border, var(--ms-border-default, currentColor)); }\n" +
".ms-box.ms-border-heavy { border-width: 2px; }\n" +
@@ -41,7 +42,9 @@ public static class LayoutCss
".ms-item { min-width: 0; box-sizing: border-box; }\n" +
".ms-flex.ms-divided > .ms-item + .ms-item { border-left: 1px solid var(--ms-separator, var(--ms-separator-default, currentColor)); padding-left: 1ch; }\n" +
".ms-figure { display: flow-root; }\n" +
- ".ms-figure-image { max-width: 100%; height: auto; }\n" +
+ ".ms-figure-image { max-width: 100%; height: auto; vertical-align: top; }\n" +
+ ".ms-box.ms-box-picture { padding: 0; width: fit-content; max-width: 100%; box-sizing: border-box; }\n" +
+ ".ms-box-picture .ms-figure-image { display: block; }\n" +
".ms-figure-art { margin: 0; font: inherit; white-space: pre; }\n" +
".ms-float-left > .ms-figure-image, .ms-float-left > .ms-figure-art { float: left; max-width: 50%; margin: 0 2ch 0.5em 0; }\n" +
".ms-float-right > .ms-figure-image, .ms-float-right > .ms-figure-art { float: right; max-width: 50%; margin: 0 0 0.5em 2ch; }\n" +
diff --git a/MarkupString.Tests/Layout/BlockLayoutTests.cs b/MarkupString.Tests/Layout/BlockLayoutTests.cs
index 1f6483a..0b3a973 100644
--- a/MarkupString.Tests/Layout/BlockLayoutTests.cs
+++ b/MarkupString.Tests/Layout/BlockLayoutTests.cs
@@ -305,6 +305,28 @@ public async Task Figure_InHtml_FloatsThePicture()
await Assert.That(html).Contains("");
}
+ [Test]
+ public async Task Figure_AloneInABox_TheBoxFitsThePicture()
+ {
+ var picture = new Figure(new ImageMarkup("https://example.com/cat.png", "A cat"), MarkupText.Empty);
+ var beside = picture with { Float = FigureFloat.Left, Beside = P("Text") };
+ var refused = Registry.WithLayoutImages(source => !source.Contains("example.com", StringComparison.Ordinal));
+
+ var fitted = BlockLayout.Build(new Frame(picture), 30).Render(MarkupFormat.Html, Registry);
+ var withText = BlockLayout.Build(new Frame(beside), 30).Render(MarkupFormat.Html, Registry);
+ var art = BlockLayout.Build(new Frame(picture with { Art = P("=^.^=") }), 30).Render(MarkupFormat.Html, refused);
+ var tight = BlockLayout.Build(new Frame(P("Text")) { Padding = 0 }, 30).Render(MarkupFormat.Html, Registry);
+
+ await Assert.That(fitted).Contains(" ");
+ await Assert.That(withText).Contains("
");
+ await Assert.That(art).Contains("");
+ await Assert.That(tight).Contains("");
+ // The picture sits on the top of its line rather than its baseline, so no gap for descenders is left under it.
+ await Assert.That(LayoutCss.Fixed).Contains(".ms-figure-image { max-width: 100%; height: auto; vertical-align: top; }");
+ await Assert.That(LayoutCss.Fixed).Contains(".ms-box.ms-box-picture { padding: 0; width: fit-content; max-width: 100%; box-sizing: border-box; }");
+ await Assert.That(LayoutCss.Fixed).Contains(".ms-box { margin: 0; padding: 0 var(--ms-pad, 1ch);");
+ }
+
[Test]
public async Task Figure_RefusedPicture_ShowsItsArt()
{