diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7af2332..ff9e75a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,6 +54,7 @@ jobs: for project in \ src/FlexRender.Core/FlexRender.Core.csproj \ src/FlexRender.Yaml/FlexRender.Yaml.csproj \ + src/FlexRender.Xml/FlexRender.Xml.csproj \ src/FlexRender.Http/FlexRender.Http.csproj \ src/FlexRender.Skia.Render/FlexRender.Skia.Render.csproj \ src/FlexRender.ImageSharp.Render/FlexRender.ImageSharp.Render.csproj \ diff --git a/AGENTS.md b/AGENTS.md index 9be33f2..3a9dbfb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -320,7 +320,7 @@ The release workflow (`.github/workflows/release.yml`) publishes all packages to | Category | Packages | |----------|----------| -| Core | `FlexRender.Core`, `FlexRender.Yaml`, `FlexRender.Http` | +| Core | `FlexRender.Core`, `FlexRender.Yaml`, `FlexRender.Xml`, `FlexRender.Http` | | Renderers | `FlexRender.Skia.Render`, `FlexRender.ImageSharp.Render`, `FlexRender.Svg.Render` | | QR providers | `FlexRender.QrCode.Skia.Render`, `FlexRender.QrCode.ImageSharp.Render`, `FlexRender.QrCode.Svg.Render` | | Barcode providers | `FlexRender.Barcode.Skia.Render`, `FlexRender.Barcode.ImageSharp.Render`, `FlexRender.Barcode.Svg.Render` | diff --git a/src/FlexRender.Core/Layout/ColumnFlexLayoutStrategy.cs b/src/FlexRender.Core/Layout/ColumnFlexLayoutStrategy.cs index b5fe38f..2a137da 100644 --- a/src/FlexRender.Core/Layout/ColumnFlexLayoutStrategy.cs +++ b/src/FlexRender.Core/Layout/ColumnFlexLayoutStrategy.cs @@ -215,7 +215,12 @@ internal static void LayoutColumnFlex(LayoutNode node, FlexElement flex, LayoutC pos += m.Top.IsAuto ? spacePerAuto : m.Top.ResolvedPixels; child.Y = pos; pos += child.Height; - pos += m.Bottom.IsAuto ? spacePerAuto : m.Bottom.ResolvedPixels; + var autoBottom = m.Bottom.IsAuto ? spacePerAuto : m.Bottom.ResolvedPixels; + pos += autoBottom; + + // Store resolved outer right/bottom margins for trailing-margin auto-size. + child.MarginBottom = Math.Max(0f, autoBottom); + child.MarginRight = Math.Max(0f, m.Right.IsAuto ? 0f : m.Right.ResolvedPixels); // Cross axis auto margins override align-items (horizontal for column) ApplyColumnCrossAxisMargins(child, m, flex, padding, crossAxisSize); @@ -280,6 +285,10 @@ internal static void LayoutColumnFlex(LayoutNode node, FlexElement flex, LayoutC var mLeft = Math.Max(0f, m.Left.ResolvedPixels); var mRight = Math.Max(0f, m.Right.ResolvedPixels); + // Store resolved outer right/bottom margins for trailing-margin auto-size. + child.MarginBottom = mBottom; + child.MarginRight = mRight; + // Check for cross axis auto margins even when main axis has no auto margins if (m.CrossAxisAutoCount(isColumn: true) > 0) { diff --git a/src/FlexRender.Core/Layout/LayoutEngine.cs b/src/FlexRender.Core/Layout/LayoutEngine.cs index 73b6047..d2371b9 100644 --- a/src/FlexRender.Core/Layout/LayoutEngine.cs +++ b/src/FlexRender.Core/Layout/LayoutEngine.cs @@ -401,8 +401,10 @@ private LayoutNode LayoutFlexElement(FlexElement flex, LayoutContext context) MirrorRowXPositions(node, effectivePadding); } - // Calculate height if not specified (skip for wrapped containers — they set height in LayoutWrappedFlex) - if (height == 0f && node.Children.Count > 0 && flex.Wrap.Value == FlexWrap.NoWrap) + // Compute auto height when the strategy did not set it — covers no-wrap and + // column-wrap. Row-wrap already sets height (its cross axis), so node.Height is + // non-zero there and this is skipped. + if (height == 0f && node.Children.Count > 0 && node.Height == 0f) { node.Height = LayoutHelpers.CalculateTotalHeight(node) + effectivePadding.Bottom; } @@ -594,6 +596,7 @@ private LayoutNode LayoutTextElement(TextElement text, LayoutContext context) var totalHeight = contentHeight + padding.Vertical + border.Vertical; var node = new LayoutNode(text, 0, 0, totalWidth, totalHeight); + node.ContentInset = CombineInset(padding, border); node.TextLines = textLines; node.ComputedLineHeight = computedLineHeight; node.Baseline = padding.Top + border.Top.Width + textBaseline; @@ -663,7 +666,7 @@ private static LayoutNode LayoutQrElement(QrElement qr, LayoutContext context) var totalWidth = contentWidth + padding.Horizontal + border.Horizontal; var totalHeight = contentHeight + padding.Vertical + border.Vertical; - return new LayoutNode(qr, 0, 0, totalWidth, totalHeight) { ComputedFontSize = context.FontSize }; + return new LayoutNode(qr, 0, 0, totalWidth, totalHeight) { ComputedFontSize = context.FontSize, ContentInset = CombineInset(padding, border) }; } /// @@ -683,7 +686,7 @@ private static LayoutNode LayoutBarcodeElement(BarcodeElement barcode, LayoutCon var totalWidth = contentWidth + padding.Horizontal + border.Horizontal; var totalHeight = contentHeight + padding.Vertical + border.Vertical; - return new LayoutNode(barcode, 0, 0, totalWidth, totalHeight) { ComputedFontSize = context.FontSize }; + return new LayoutNode(barcode, 0, 0, totalWidth, totalHeight) { ComputedFontSize = context.FontSize, ContentInset = CombineInset(padding, border) }; } /// @@ -702,7 +705,7 @@ private static LayoutNode LayoutImageElement(ImageElement image, LayoutContext c var totalWidth = contentWidth + padding.Horizontal + border.Horizontal; var totalHeight = contentHeight + padding.Vertical + border.Vertical; - return new LayoutNode(image, 0, 0, totalWidth, totalHeight) { ComputedFontSize = context.FontSize }; + return new LayoutNode(image, 0, 0, totalWidth, totalHeight) { ComputedFontSize = context.FontSize, ContentInset = CombineInset(padding, border) }; } /// @@ -720,7 +723,7 @@ private static LayoutNode LayoutSvgElement(SvgElement svg, LayoutContext context var totalWidth = contentWidth + padding.Horizontal + border.Horizontal; var totalHeight = contentHeight + padding.Vertical + border.Vertical; - return new LayoutNode(svg, 0, 0, totalWidth, totalHeight) { ComputedFontSize = context.FontSize }; + return new LayoutNode(svg, 0, 0, totalWidth, totalHeight) { ComputedFontSize = context.FontSize, ContentInset = CombineInset(padding, border) }; } /// @@ -750,7 +753,7 @@ private static LayoutNode LayoutSeparatorElement(SeparatorElement separator, Lay var totalWidth = contentWidth + padding.Horizontal + border.Horizontal; var totalHeight = contentHeight + padding.Vertical + border.Vertical; - return new LayoutNode(separator, 0, 0, totalWidth, totalHeight) { ComputedFontSize = context.FontSize }; + return new LayoutNode(separator, 0, 0, totalWidth, totalHeight) { ComputedFontSize = context.FontSize, ContentInset = CombineInset(padding, border) }; } /// @@ -773,9 +776,23 @@ private static LayoutNode LayoutShapeElement(TemplateElement shape, LayoutContex var totalWidth = contentWidth + padding.Horizontal + border.Horizontal; var totalHeight = contentHeight + padding.Vertical + border.Vertical; - return new LayoutNode(shape, 0, 0, totalWidth, totalHeight) { ComputedFontSize = context.FontSize }; + return new LayoutNode(shape, 0, 0, totalWidth, totalHeight) { ComputedFontSize = context.FontSize, ContentInset = CombineInset(padding, border) }; } + /// + /// Combines padding and border widths into a single content inset (per side). + /// This is the distance from a leaf element's box edge to its content area, used by + /// renderers to inset content while keeping the background and border on the full box. + /// + /// The resolved padding values (already clamped to non-negative). + /// The resolved border values for all four sides. + /// The combined content inset per side. + private static PaddingValues CombineInset(PaddingValues padding, BorderValues border) => new( + padding.Top + border.Top.Width, + padding.Right + border.Right.Width, + padding.Bottom + border.Bottom.Width, + padding.Left + border.Left.Width); + /// /// Checks whether any flow (non-absolute) child in the node has a non-default /// value. diff --git a/src/FlexRender.Core/Layout/LayoutHelpers.cs b/src/FlexRender.Core/Layout/LayoutHelpers.cs index 5db6536..bbd5f4e 100644 --- a/src/FlexRender.Core/Layout/LayoutHelpers.cs +++ b/src/FlexRender.Core/Layout/LayoutHelpers.cs @@ -229,8 +229,9 @@ internal static float CalculateTotalHeight(LayoutNode node) foreach (var child in node.Children) { if (child.Element.Position.Value == Position.Absolute) continue; - if (child.Bottom > maxBottom) - maxBottom = child.Bottom; + var bottom = child.Bottom + child.MarginBottom; + if (bottom > maxBottom) + maxBottom = bottom; } return maxBottom; } @@ -249,7 +250,7 @@ internal static float CalculateTotalWidth(LayoutNode node) foreach (var child in node.Children) { if (child.Element.Position.Value == Position.Absolute) continue; - var right = child.X + child.Width; + var right = child.X + child.Width + child.MarginRight; if (right > maxRight) maxRight = right; } diff --git a/src/FlexRender.Core/Layout/LayoutNode.cs b/src/FlexRender.Core/Layout/LayoutNode.cs index 705eab2..eff8892 100644 --- a/src/FlexRender.Core/Layout/LayoutNode.cs +++ b/src/FlexRender.Core/Layout/LayoutNode.cs @@ -1,3 +1,4 @@ +using FlexRender.Layout.Units; using FlexRender.Parsing.Ast; namespace FlexRender.Layout; @@ -67,6 +68,29 @@ public sealed class LayoutNode /// public LayoutDiagnostics? Diagnostics { get; set; } + /// + /// The content inset (padding + border) for each side, in pixels. This is the distance + /// from the element's box edge to its content area. Renderers subtract this inset when + /// drawing leaf content so that padding insets content on all sides, while the background + /// and border keep using the full box. Defaults to ; + /// container (flex) nodes leave it zero because their children are already offset by padding. + /// + public PaddingValues ContentInset { get; set; } = PaddingValues.Zero; + + /// + /// The resolved right (outer) margin in pixels. Used by auto-size calculations to include + /// a trailing child's right margin in the parent's content extent. The left/top margins are + /// already baked into / by the flex strategies. Defaults to 0. + /// + public float MarginRight { get; set; } + + /// + /// The resolved bottom (outer) margin in pixels. Used by auto-size calculations to include + /// a trailing child's bottom margin in the parent's content extent. The left/top margins are + /// already baked into / by the flex strategies. Defaults to 0. + /// + public float MarginBottom { get; set; } + /// Right edge (X + Width). public float Right => X + Width; diff --git a/src/FlexRender.Core/Layout/RowFlexLayoutStrategy.cs b/src/FlexRender.Core/Layout/RowFlexLayoutStrategy.cs index 85d9192..140182b 100644 --- a/src/FlexRender.Core/Layout/RowFlexLayoutStrategy.cs +++ b/src/FlexRender.Core/Layout/RowFlexLayoutStrategy.cs @@ -222,7 +222,12 @@ internal static void LayoutRowFlex(LayoutNode node, FlexElement flex, LayoutCont pos += m.Left.IsAuto ? spacePerAuto : m.Left.ResolvedPixels; child.X = pos; pos += child.Width; - pos += m.Right.IsAuto ? spacePerAuto : m.Right.ResolvedPixels; + var autoRight = m.Right.IsAuto ? spacePerAuto : m.Right.ResolvedPixels; + pos += autoRight; + + // Store resolved outer right/bottom margins for trailing-margin auto-size. + child.MarginRight = Math.Max(0f, autoRight); + child.MarginBottom = Math.Max(0f, m.Bottom.IsAuto ? 0f : m.Bottom.ResolvedPixels); // Cross axis auto margins override align-items (vertical for row) ApplyRowCrossAxisMargins(child, m, flex, padding, crossAxisSize, hasExplicitHeight); @@ -302,6 +307,10 @@ internal static void LayoutRowFlex(LayoutNode node, FlexElement flex, LayoutCont var mTop = Math.Max(0f, m.Top.ResolvedPixels); var mBottom = Math.Max(0f, m.Bottom.ResolvedPixels); + // Store resolved outer right/bottom margins for trailing-margin auto-size. + child.MarginRight = mRight; + child.MarginBottom = mBottom; + // Add child margin to X position child.X = x + mLeft; diff --git a/src/FlexRender.Core/Layout/WrappedFlexLayoutStrategy.cs b/src/FlexRender.Core/Layout/WrappedFlexLayoutStrategy.cs index a32b7a9..62c059d 100644 --- a/src/FlexRender.Core/Layout/WrappedFlexLayoutStrategy.cs +++ b/src/FlexRender.Core/Layout/WrappedFlexLayoutStrategy.cs @@ -511,6 +511,11 @@ private static void ResolveFlexForLine(LayoutNode node, FlexElement flex, Layout var child = lineChildren[i]; var childMargin = PaddingParser.Parse(child.Element.Margin.Value, context.ContainerWidth, context.FontSize).ClampNegatives(); + // Record resolved trailing margins so CalculateTotalHeight/Width (used for + // auto-sizing the container) accounts for them, matching the non-wrap strategies. + child.MarginRight = childMargin.Right; + child.MarginBottom = childMargin.Bottom; + if (isColumn) { child.Y = pos + childMargin.Top; diff --git a/src/FlexRender.ImageSharp.Render/Rendering/ImageSharpRenderingEngine.cs b/src/FlexRender.ImageSharp.Render/Rendering/ImageSharpRenderingEngine.cs index ea1180c..294e575 100644 --- a/src/FlexRender.ImageSharp.Render/Rendering/ImageSharpRenderingEngine.cs +++ b/src/FlexRender.ImageSharp.Render/Rendering/ImageSharpRenderingEngine.cs @@ -202,16 +202,22 @@ private void DrawElement( var effectiveFontSize = node.ComputedFontSize > 0 ? node.ComputedFontSize : _baseFontSize; var rotation = RotationHelper.ParseRotation(element.Rotate.Value); + // Content is inset by padding + border so it sits inside the box on all sides. + // The background (drawn by the caller) keeps using the full box. + var inset = node.ContentInset; + var cw = Math.Max(0f, width - inset.Horizontal); + var ch = Math.Max(0f, height - inset.Vertical); + if (RotationHelper.HasRotation(rotation)) { DrawWithRotation(ctx, x, y, width, height, rotation, bufferCtx => { - DrawElementContent(bufferCtx, element, 0, 0, width, height, effectiveFontSize, imageCache); + DrawElementContent(bufferCtx, element, inset.Left, inset.Top, cw, ch, effectiveFontSize, imageCache); }); } else { - DrawElementContent(ctx, element, x, y, width, height, effectiveFontSize, imageCache); + DrawElementContent(ctx, element, x + inset.Left, y + inset.Top, cw, ch, effectiveFontSize, imageCache); } } diff --git a/src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs b/src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs index 73fc002..3ec4999 100644 --- a/src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs +++ b/src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs @@ -292,31 +292,39 @@ private void DrawElement( // Draw borders between background and content DrawBorders(canvas, element, x, y, width, height, borderRadius, effectiveFontSize); + // Content (text, bitmaps, shapes) is inset by padding + border so it sits inside the + // box on all sides. Background and border above keep using the full box (x, y, w, h). + var inset = node.ContentInset; + var cx = x + inset.Left; + var cy = y + inset.Top; + var cw = Math.Max(0f, width - inset.Horizontal); + var ch = Math.Max(0f, height - inset.Vertical); + switch (element) { case TextElement text: - var bounds = new SKRect(x, y, x + width, y + height); + var bounds = new SKRect(cx, cy, cx + cw, cy + ch); _textRenderer.DrawText(canvas, text, bounds, effectiveFontSize, renderOptions, direction, node.TextLines, node.ComputedLineHeight); break; case QrElement qr when _qrProvider is not null: - using (var bitmap = GetSkiaBitmap(_qrProvider, qr, (int)width, (int)height)) + using (var bitmap = GetSkiaBitmap(_qrProvider, qr, (int)cw, (int)ch)) { - DrawBitmapWithRotation(canvas, bitmap, element, x, y, width, height); + DrawBitmapWithRotation(canvas, bitmap, element, cx, cy, cw, ch); } break; case BarcodeElement barcode when _barcodeProvider is not null: - using (var bitmap = GetSkiaBitmap(_barcodeProvider, barcode, (int)width, (int)height)) + using (var bitmap = GetSkiaBitmap(_barcodeProvider, barcode, (int)cw, (int)ch)) { - DrawBitmapWithRotation(canvas, bitmap, element, x, y, width, height); + DrawBitmapWithRotation(canvas, bitmap, element, cx, cy, cw, ch); } break; case SvgElement svg when _svgProvider is not null: - using (var bitmap = GetSkiaBitmap(_svgProvider, svg, (int)width, (int)height)) + using (var bitmap = GetSkiaBitmap(_svgProvider, svg, (int)cw, (int)ch)) { - DrawBitmapWithRotation(canvas, bitmap, element, x, y, width, height); + DrawBitmapWithRotation(canvas, bitmap, element, cx, cy, cw, ch); } break; @@ -325,38 +333,38 @@ private void DrawElement( image, imageCache, renderOptions.Antialiasing, - layoutWidth: (int)width, - layoutHeight: (int)height)) + layoutWidth: (int)cw, + layoutHeight: (int)ch)) { - DrawBitmapWithRotation(canvas, bitmap, element, x, y, width, height); + DrawBitmapWithRotation(canvas, bitmap, element, cx, cy, cw, ch); } break; case RectElement rect: - ShapeRenderer.DrawRect(canvas, rect, x, y, width, height, effectiveFontSize, renderOptions.Antialiasing); + ShapeRenderer.DrawRect(canvas, rect, cx, cy, cw, ch, effectiveFontSize, renderOptions.Antialiasing); break; case CircleElement circle: - ShapeRenderer.DrawCircle(canvas, circle, x, y, width, height, renderOptions.Antialiasing); + ShapeRenderer.DrawCircle(canvas, circle, cx, cy, cw, ch, renderOptions.Antialiasing); break; case EllipseElement ellipse: - ShapeRenderer.DrawEllipse(canvas, ellipse, x, y, width, height, renderOptions.Antialiasing); + ShapeRenderer.DrawEllipse(canvas, ellipse, cx, cy, cw, ch, renderOptions.Antialiasing); break; case DrawElement drawEl: - ShapeRenderer.DrawShapes(canvas, drawEl, x, y, width, height, renderOptions.Antialiasing); + ShapeRenderer.DrawShapes(canvas, drawEl, cx, cy, cw, ch, renderOptions.Antialiasing); break; case ChartElement chart: ChartRenderer.Draw( - canvas, chart, x, y, width, height, + canvas, chart, cx, cy, cw, ch, _fontManager?.GetTypeface("main"), renderOptions.Antialiasing); break; case SeparatorElement separator: - DrawSeparator(canvas, separator, x, y, width, height, renderOptions.Antialiasing); + DrawSeparator(canvas, separator, cx, cy, cw, ch, renderOptions.Antialiasing); break; case FlexElement: diff --git a/src/FlexRender.Svg.Render/Rendering/SvgRenderingEngine.cs b/src/FlexRender.Svg.Render/Rendering/SvgRenderingEngine.cs index a550d43..13d2c1c 100644 --- a/src/FlexRender.Svg.Render/Rendering/SvgRenderingEngine.cs +++ b/src/FlexRender.Svg.Render/Rendering/SvgRenderingEngine.cs @@ -307,52 +307,60 @@ private void DrawElement( // Draw borders DrawBorders(sb, element, x, y, width, height, borderRadius); + // Content is inset by padding + border so it sits inside the box on all sides. + // Background and border above keep using the full box (x, y, width, height). + var inset = node.ContentInset; + var cx = x + inset.Left; + var cy = y + inset.Top; + var cw = Math.Max(0f, width - inset.Horizontal); + var ch = Math.Max(0f, height - inset.Vertical); + // Draw element-specific content switch (element) { case TextElement text: - DrawText(sb, text, fontFamilyMap, x, y, width, height, direction, node.TextLines, node.ComputedLineHeight); + DrawText(sb, text, fontFamilyMap, cx, cy, cw, ch, direction, node.TextLines, node.ComputedLineHeight); break; case SeparatorElement separator: - DrawSeparator(sb, separator, x, y, width, height); + DrawSeparator(sb, separator, cx, cy, cw, ch); break; case ImageElement image: - DrawImage(sb, image, x, y, width, height); + DrawImage(sb, image, cx, cy, cw, ch); break; case SvgElement svg when _svgElementSvgProvider is not null: - DrawSvgContentProvider(sb, _svgElementSvgProvider, svg, x, y, width, height); + DrawSvgContentProvider(sb, _svgElementSvgProvider, svg, cx, cy, cw, ch); break; case SvgElement svg: - DrawSvgElement(sb, svg, x, y, width, height); + DrawSvgElement(sb, svg, cx, cy, cw, ch); break; case QrElement qr when _qrSvgProvider is not null: - DrawSvgContentProvider(sb, _qrSvgProvider, qr, x, y, width, height); + DrawSvgContentProvider(sb, _qrSvgProvider, qr, cx, cy, cw, ch); break; case QrElement qr when _qrProvider is ISvgContentProvider svgQrProvider: - DrawSvgContentProvider(sb, svgQrProvider, qr, x, y, width, height); + DrawSvgContentProvider(sb, svgQrProvider, qr, cx, cy, cw, ch); break; case QrElement qr when _qrProvider is not null: { - var result = _qrProvider.Generate(qr, (int)width, (int)height); - DrawBitmapElement(sb, result, x, y, width, height); + var result = _qrProvider.Generate(qr, (int)cw, (int)ch); + DrawBitmapElement(sb, result, cx, cy, cw, ch); break; } case BarcodeElement barcode when _barcodeSvgProvider is not null: - DrawSvgContentProvider(sb, _barcodeSvgProvider, barcode, x, y, width, height); + DrawSvgContentProvider(sb, _barcodeSvgProvider, barcode, cx, cy, cw, ch); break; case BarcodeElement barcode when _barcodeProvider is not null: { - var result = _barcodeProvider.Generate(barcode, (int)width, (int)height); - DrawBitmapElement(sb, result, x, y, width, height); + var result = _barcodeProvider.Generate(barcode, (int)cw, (int)ch); + DrawBitmapElement(sb, result, cx, cy, cw, ch); break; } diff --git a/tests/FlexRender.ImageSharp.Tests/Snapshots/ImageSharpVisualSnapshotTests.cs b/tests/FlexRender.ImageSharp.Tests/Snapshots/ImageSharpVisualSnapshotTests.cs index a59a113..293cecc 100644 --- a/tests/FlexRender.ImageSharp.Tests/Snapshots/ImageSharpVisualSnapshotTests.cs +++ b/tests/FlexRender.ImageSharp.Tests/Snapshots/ImageSharpVisualSnapshotTests.cs @@ -96,6 +96,29 @@ public void TextMultiline() AssertSnapshot("is_text_multiline", template, new ObjectValue()); } + /// + /// Tests that a top-level leaf text element with padding insets its content on all + /// four sides (not just right/bottom). Regression test for issue #10. + /// + [Fact] + public void LeafPaddingInsetsContent() + { + if (!OperatingSystem.IsMacOS()) return; + + var template = CreateTemplate(300, 200); + template.Canvas.Fixed = FixedDimension.Width; + template.AddElement(new TextElement + { + Content = "Line one of padded text\nLine two of padded text\nLine three", + Size = "16", + Color = "#003366", + Background = "#cce5ff", + Padding = "40" + }); + + AssertSnapshot("is_leaf-padding-insets-content", template, new ObjectValue()); + } + /// /// Tests template variable substitution in text content. /// diff --git a/tests/FlexRender.ImageSharp.Tests/Snapshots/golden/is_leaf-padding-insets-content.png b/tests/FlexRender.ImageSharp.Tests/Snapshots/golden/is_leaf-padding-insets-content.png new file mode 100644 index 0000000..b35e56f Binary files /dev/null and b/tests/FlexRender.ImageSharp.Tests/Snapshots/golden/is_leaf-padding-insets-content.png differ diff --git a/tests/FlexRender.Tests/Layout/LayoutEnginePaddingMarginTests.cs b/tests/FlexRender.Tests/Layout/LayoutEnginePaddingMarginTests.cs index a9114aa..af2f897 100644 --- a/tests/FlexRender.Tests/Layout/LayoutEnginePaddingMarginTests.cs +++ b/tests/FlexRender.Tests/Layout/LayoutEnginePaddingMarginTests.cs @@ -1264,4 +1264,38 @@ public void ComputeLayout_RowAlignStretch_MarginSubtractedFromHeight() // Stretch height = 200 - 10 - 10 = 180 Assert.Equal(180f, child.Height, 1); } + + /// + /// Verifies that a top-level leaf element's trailing (bottom) margin is included in + /// the auto-computed canvas height. Regression test for the box-model bug where the + /// canvas auto-height excluded the last child's bottom margin (issue #10). + /// + [Fact] + public void TextElement_WithMargin_TrailingBottomMarginIncludedInCanvasHeight() + { + var template = new Template + { + Canvas = new CanvasSettings { Width = 200, Fixed = FixedDimension.Width }, + Elements = new List + { + new TextElement + { + Content = "Test", + Size = "16", + Margin = "80" + } + } + }; + + var root = _engine.ComputeLayout(template); + var textNode = root.Children[0]; + + // The canvas auto-height must include the child's bottom margin, not just its + // box bottom edge. child.Y already includes the top margin (80), so the canvas + // height must reach at least child.Y + child.Height + bottomMargin(80). + var expectedMinHeight = textNode.Y + textNode.Height + 80f; + Assert.True( + root.Height >= expectedMinHeight, + $"Canvas height {root.Height} should include trailing bottom margin (>= {expectedMinHeight})."); + } } diff --git a/tests/FlexRender.Tests/Layout/LayoutEngineWrapTests.cs b/tests/FlexRender.Tests/Layout/LayoutEngineWrapTests.cs index cd1b013..b06d2a4 100644 --- a/tests/FlexRender.Tests/Layout/LayoutEngineWrapTests.cs +++ b/tests/FlexRender.Tests/Layout/LayoutEngineWrapTests.cs @@ -418,4 +418,41 @@ public void ComputeLayout_RowWrap_EmptyChildren_NoWrap() Assert.Equal(100f, flexNode.Height, 0.1f); Assert.Empty(flexNode.Children); } + + [Fact] + public void ComputeLayout_ColumnWrap_NoExplicitHeight_SizesToContentIncludingBottomMargin() + { + // Arrange: Column wrap, no explicit height, two children W=120 H=40 margin=20. + // Single column (no main-axis constraint): both stack on the main axis (Y). + // Item A: Y=20 (top margin), H=40 -> bottom edge 60, +bottom margin 20 -> 80. + // Item B: Y=100 (60 + bottom margin 20 + top margin 20), H=40 -> bottom edge 140, +bottom margin 20 -> 160. + // Container auto-height must size to content = 160 (previously collapsed to ~0). + var flex = new FlexElement + { + Direction = FlexDirection.Column, + Wrap = FlexWrap.Wrap + }; + flex.AddChild(new TextElement { Content = "AAAA", Width = "120", Height = "40", Margin = "20" }); + flex.AddChild(new TextElement { Content = "BBBB", Width = "120", Height = "40", Margin = "20" }); + + var template = new Template + { + Canvas = new CanvasSettings { Width = 300 }, + Elements = new List { flex } + }; + + // Act + var root = _engine.ComputeLayout(template); + + // Assert + var flexNode = root.Children[0]; + + Assert.Equal(20f, flexNode.Children[0].Y, 0.1f); + Assert.Equal(40f, flexNode.Children[0].Height, 0.1f); + Assert.Equal(100f, flexNode.Children[1].Y, 0.1f); + Assert.Equal(40f, flexNode.Children[1].Height, 0.1f); + + // Container auto-height includes the trailing bottom margin of the last child. + Assert.Equal(160f, flexNode.Height, 0.1f); + } } diff --git a/tests/FlexRender.Tests/Snapshots/SvgSnapshotTests.cs b/tests/FlexRender.Tests/Snapshots/SvgSnapshotTests.cs index f4e4c40..b8998d2 100644 --- a/tests/FlexRender.Tests/Snapshots/SvgSnapshotTests.cs +++ b/tests/FlexRender.Tests/Snapshots/SvgSnapshotTests.cs @@ -251,4 +251,27 @@ public async Task SvgBarcodeBasic() await AssertSvgSnapshot("svg_barcode_basic", template, new ObjectValue()); } + + /// + /// Tests that a leaf element's padding insets its content on all sides. + /// A top-level text element with uniform padding and a visible background + /// must keep its background on the full box while the text lines are + /// inset by the padding on the top and left (not only bottom and right). + /// + [Fact] + public async Task SvgLeafPaddingInsetsContent() + { + var template = CreateTemplate(300, 200); + + template.AddElement(new TextElement + { + Content = "Line one of padded text\nLine two of padded text\nLine three", + Size = "16", + Color = "#003366", + Background = "#cce5ff", + Padding = "40" + }); + + await AssertSvgSnapshot("svg_leaf_padding_insets_content", template, new ObjectValue()); + } } diff --git a/tests/FlexRender.Tests/Snapshots/VisualSnapshotTests.cs b/tests/FlexRender.Tests/Snapshots/VisualSnapshotTests.cs index 7705318..4936c96 100644 --- a/tests/FlexRender.Tests/Snapshots/VisualSnapshotTests.cs +++ b/tests/FlexRender.Tests/Snapshots/VisualSnapshotTests.cs @@ -1329,6 +1329,28 @@ public async Task BackgroundWithPadding() await AssertSnapshot("background_with_padding", template, new ObjectValue()); } + /// + /// Tests that a top-level leaf text element with padding insets its content on all + /// four sides (not just right/bottom). Regression test for issue #10 where leaf padding + /// only inflated the box and the content stayed at the box's top-left corner. + /// + [Fact] + public async Task LeafPaddingInsetsContent() + { + var template = CreateTemplate(300, 200); + + template.AddElement(new TextElement + { + Content = "Line one of padded text\nLine two of padded text\nLine three", + Size = "16", + Color = "#003366", + Background = "#cce5ff", + Padding = "40" + }); + + await AssertSnapshot("leaf-padding-insets-content", template, new ObjectValue()); + } + /// /// Tests element with both margin and background to verify that /// margin creates space outside the background area. diff --git a/tests/FlexRender.Tests/Snapshots/golden/flex_with_margin.png b/tests/FlexRender.Tests/Snapshots/golden/flex_with_margin.png index 1dd73dd..02eb349 100644 Binary files a/tests/FlexRender.Tests/Snapshots/golden/flex_with_margin.png and b/tests/FlexRender.Tests/Snapshots/golden/flex_with_margin.png differ diff --git a/tests/FlexRender.Tests/Snapshots/golden/leaf-padding-insets-content.png b/tests/FlexRender.Tests/Snapshots/golden/leaf-padding-insets-content.png new file mode 100644 index 0000000..159ffe2 Binary files /dev/null and b/tests/FlexRender.Tests/Snapshots/golden/leaf-padding-insets-content.png differ diff --git a/tests/FlexRender.Tests/Snapshots/golden/ndc_receipt_composite.png b/tests/FlexRender.Tests/Snapshots/golden/ndc_receipt_composite.png index 9b6a881..104a7ee 100644 Binary files a/tests/FlexRender.Tests/Snapshots/golden/ndc_receipt_composite.png and b/tests/FlexRender.Tests/Snapshots/golden/ndc_receipt_composite.png differ diff --git a/tests/FlexRender.Tests/Snapshots/golden/svg_leaf_padding_insets_content.svg b/tests/FlexRender.Tests/Snapshots/golden/svg_leaf_padding_insets_content.svg new file mode 100644 index 0000000..e0444ab --- /dev/null +++ b/tests/FlexRender.Tests/Snapshots/golden/svg_leaf_padding_insets_content.svg @@ -0,0 +1 @@ +Line one of padded textLine two of padded textLine three \ No newline at end of file