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("
\"A
Text
"); } + [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() {