Skip to content

Commit daa593f

Browse files
authored
fix: box-model padding/margin (#10, #12) + FlexRender.Xml release packaging (#13)
* build: add FlexRender.Xml to NuGet release pack list * fix(layout): inset leaf content by padding+border and include trailing margin in auto-size (#10) Two box-model bugs are fixed: - Leaf padding now insets content on all sides. Layout already sized the box as content + padding + border, but both renderers drew leaf content into the full box, leaving padding only on the right/bottom. LayoutNode now carries a ContentInset (padding + border per side) populated at each leaf layout site; the Skia and ImageSharp renderers keep background and border on the full box but draw content into the inset rect. - A trailing child's bottom/right margin is now included in canvas/container auto-size. The flex strategies store each child's resolved outer Right and Bottom margins on LayoutNode, and CalculateTotalHeight/Width add them so the last child's trailing margin is no longer clipped. Adds a layout regression test for the trailing-margin auto-height and Skia and ImageSharp snapshot tests proving leaf padding insets content on all four sides. Regenerated flex_with_margin and ndc_receipt_composite goldens (now taller by the previously dropped trailing margin). * fix(svg): inset leaf content by padding+border in SvgRenderingEngine The SVG rendering backend (WithSvg) was missed in the box-model fix that corrected the Skia and ImageSharp renderers. Leaf content was drawn into the full box, so padding/border was only applied on the right/bottom edges for SVG/PNG output via the SVG backend. Compute the content inset from node.ContentInset (padding+border per side) and pass the inset cx/cy/cw/ch to every content draw call (text, separator, image, svg, qr, barcode), keeping background and borders on the full box. Add svg_leaf_padding_insets_content snapshot test verifying the background rect stays on the full box while text is inset on all sides. * fix: column-wrap flex container collapses to zero height without explicit height A flex container with direction: column + wrap: wrap and no explicit height collapsed to ~0 height, clipping all content. The wrapped strategy only set the cross-axis auto dimension (width for column-wrap), never the main-axis (height), and the engine skipped fallback height calculation for all wrap containers. - WrappedFlexLayoutStrategy now records resolved trailing margins (MarginRight/MarginBottom) on wrap children so CalculateTotalHeight/ Width account for them, matching the non-wrap strategies. - LayoutEngine computes auto height whenever the strategy left node.Height unset (node.Height == 0f), covering no-wrap and column-wrap; row-wrap already sets height and is skipped.
1 parent f9d8c81 commit daa593f

21 files changed

Lines changed: 272 additions & 44 deletions

.github/workflows/release.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ jobs:
5454
for project in \
5555
src/FlexRender.Core/FlexRender.Core.csproj \
5656
src/FlexRender.Yaml/FlexRender.Yaml.csproj \
57+
src/FlexRender.Xml/FlexRender.Xml.csproj \
5758
src/FlexRender.Http/FlexRender.Http.csproj \
5859
src/FlexRender.Skia.Render/FlexRender.Skia.Render.csproj \
5960
src/FlexRender.ImageSharp.Render/FlexRender.ImageSharp.Render.csproj \

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,7 @@ The release workflow (`.github/workflows/release.yml`) publishes all packages to
320320

321321
| Category | Packages |
322322
|----------|----------|
323-
| Core | `FlexRender.Core`, `FlexRender.Yaml`, `FlexRender.Http` |
323+
| Core | `FlexRender.Core`, `FlexRender.Yaml`, `FlexRender.Xml`, `FlexRender.Http` |
324324
| Renderers | `FlexRender.Skia.Render`, `FlexRender.ImageSharp.Render`, `FlexRender.Svg.Render` |
325325
| QR providers | `FlexRender.QrCode.Skia.Render`, `FlexRender.QrCode.ImageSharp.Render`, `FlexRender.QrCode.Svg.Render` |
326326
| Barcode providers | `FlexRender.Barcode.Skia.Render`, `FlexRender.Barcode.ImageSharp.Render`, `FlexRender.Barcode.Svg.Render` |

src/FlexRender.Core/Layout/ColumnFlexLayoutStrategy.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,12 @@ internal static void LayoutColumnFlex(LayoutNode node, FlexElement flex, LayoutC
215215
pos += m.Top.IsAuto ? spacePerAuto : m.Top.ResolvedPixels;
216216
child.Y = pos;
217217
pos += child.Height;
218-
pos += m.Bottom.IsAuto ? spacePerAuto : m.Bottom.ResolvedPixels;
218+
var autoBottom = m.Bottom.IsAuto ? spacePerAuto : m.Bottom.ResolvedPixels;
219+
pos += autoBottom;
220+
221+
// Store resolved outer right/bottom margins for trailing-margin auto-size.
222+
child.MarginBottom = Math.Max(0f, autoBottom);
223+
child.MarginRight = Math.Max(0f, m.Right.IsAuto ? 0f : m.Right.ResolvedPixels);
219224

220225
// Cross axis auto margins override align-items (horizontal for column)
221226
ApplyColumnCrossAxisMargins(child, m, flex, padding, crossAxisSize);
@@ -280,6 +285,10 @@ internal static void LayoutColumnFlex(LayoutNode node, FlexElement flex, LayoutC
280285
var mLeft = Math.Max(0f, m.Left.ResolvedPixels);
281286
var mRight = Math.Max(0f, m.Right.ResolvedPixels);
282287

288+
// Store resolved outer right/bottom margins for trailing-margin auto-size.
289+
child.MarginBottom = mBottom;
290+
child.MarginRight = mRight;
291+
283292
// Check for cross axis auto margins even when main axis has no auto margins
284293
if (m.CrossAxisAutoCount(isColumn: true) > 0)
285294
{

src/FlexRender.Core/Layout/LayoutEngine.cs

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -401,8 +401,10 @@ private LayoutNode LayoutFlexElement(FlexElement flex, LayoutContext context)
401401
MirrorRowXPositions(node, effectivePadding);
402402
}
403403

404-
// Calculate height if not specified (skip for wrapped containers — they set height in LayoutWrappedFlex)
405-
if (height == 0f && node.Children.Count > 0 && flex.Wrap.Value == FlexWrap.NoWrap)
404+
// Compute auto height when the strategy did not set it — covers no-wrap and
405+
// column-wrap. Row-wrap already sets height (its cross axis), so node.Height is
406+
// non-zero there and this is skipped.
407+
if (height == 0f && node.Children.Count > 0 && node.Height == 0f)
406408
{
407409
node.Height = LayoutHelpers.CalculateTotalHeight(node) + effectivePadding.Bottom;
408410
}
@@ -594,6 +596,7 @@ private LayoutNode LayoutTextElement(TextElement text, LayoutContext context)
594596
var totalHeight = contentHeight + padding.Vertical + border.Vertical;
595597

596598
var node = new LayoutNode(text, 0, 0, totalWidth, totalHeight);
599+
node.ContentInset = CombineInset(padding, border);
597600
node.TextLines = textLines;
598601
node.ComputedLineHeight = computedLineHeight;
599602
node.Baseline = padding.Top + border.Top.Width + textBaseline;
@@ -663,7 +666,7 @@ private static LayoutNode LayoutQrElement(QrElement qr, LayoutContext context)
663666
var totalWidth = contentWidth + padding.Horizontal + border.Horizontal;
664667
var totalHeight = contentHeight + padding.Vertical + border.Vertical;
665668

666-
return new LayoutNode(qr, 0, 0, totalWidth, totalHeight) { ComputedFontSize = context.FontSize };
669+
return new LayoutNode(qr, 0, 0, totalWidth, totalHeight) { ComputedFontSize = context.FontSize, ContentInset = CombineInset(padding, border) };
667670
}
668671

669672
/// <summary>
@@ -683,7 +686,7 @@ private static LayoutNode LayoutBarcodeElement(BarcodeElement barcode, LayoutCon
683686
var totalWidth = contentWidth + padding.Horizontal + border.Horizontal;
684687
var totalHeight = contentHeight + padding.Vertical + border.Vertical;
685688

686-
return new LayoutNode(barcode, 0, 0, totalWidth, totalHeight) { ComputedFontSize = context.FontSize };
689+
return new LayoutNode(barcode, 0, 0, totalWidth, totalHeight) { ComputedFontSize = context.FontSize, ContentInset = CombineInset(padding, border) };
687690
}
688691

689692
/// <summary>
@@ -702,7 +705,7 @@ private static LayoutNode LayoutImageElement(ImageElement image, LayoutContext c
702705
var totalWidth = contentWidth + padding.Horizontal + border.Horizontal;
703706
var totalHeight = contentHeight + padding.Vertical + border.Vertical;
704707

705-
return new LayoutNode(image, 0, 0, totalWidth, totalHeight) { ComputedFontSize = context.FontSize };
708+
return new LayoutNode(image, 0, 0, totalWidth, totalHeight) { ComputedFontSize = context.FontSize, ContentInset = CombineInset(padding, border) };
706709
}
707710

708711
/// <summary>
@@ -720,7 +723,7 @@ private static LayoutNode LayoutSvgElement(SvgElement svg, LayoutContext context
720723
var totalWidth = contentWidth + padding.Horizontal + border.Horizontal;
721724
var totalHeight = contentHeight + padding.Vertical + border.Vertical;
722725

723-
return new LayoutNode(svg, 0, 0, totalWidth, totalHeight) { ComputedFontSize = context.FontSize };
726+
return new LayoutNode(svg, 0, 0, totalWidth, totalHeight) { ComputedFontSize = context.FontSize, ContentInset = CombineInset(padding, border) };
724727
}
725728

726729
/// <summary>
@@ -750,7 +753,7 @@ private static LayoutNode LayoutSeparatorElement(SeparatorElement separator, Lay
750753
var totalWidth = contentWidth + padding.Horizontal + border.Horizontal;
751754
var totalHeight = contentHeight + padding.Vertical + border.Vertical;
752755

753-
return new LayoutNode(separator, 0, 0, totalWidth, totalHeight) { ComputedFontSize = context.FontSize };
756+
return new LayoutNode(separator, 0, 0, totalWidth, totalHeight) { ComputedFontSize = context.FontSize, ContentInset = CombineInset(padding, border) };
754757
}
755758

756759
/// <summary>
@@ -773,9 +776,23 @@ private static LayoutNode LayoutShapeElement(TemplateElement shape, LayoutContex
773776
var totalWidth = contentWidth + padding.Horizontal + border.Horizontal;
774777
var totalHeight = contentHeight + padding.Vertical + border.Vertical;
775778

776-
return new LayoutNode(shape, 0, 0, totalWidth, totalHeight) { ComputedFontSize = context.FontSize };
779+
return new LayoutNode(shape, 0, 0, totalWidth, totalHeight) { ComputedFontSize = context.FontSize, ContentInset = CombineInset(padding, border) };
777780
}
778781

782+
/// <summary>
783+
/// Combines padding and border widths into a single content inset (per side).
784+
/// This is the distance from a leaf element's box edge to its content area, used by
785+
/// renderers to inset content while keeping the background and border on the full box.
786+
/// </summary>
787+
/// <param name="padding">The resolved padding values (already clamped to non-negative).</param>
788+
/// <param name="border">The resolved border values for all four sides.</param>
789+
/// <returns>The combined content inset per side.</returns>
790+
private static PaddingValues CombineInset(PaddingValues padding, BorderValues border) => new(
791+
padding.Top + border.Top.Width,
792+
padding.Right + border.Right.Width,
793+
padding.Bottom + border.Bottom.Width,
794+
padding.Left + border.Left.Width);
795+
779796
/// <summary>
780797
/// Checks whether any flow (non-absolute) child in the node has a non-default
781798
/// <see cref="Parsing.Ast.TemplateElement.Order"/> value.

src/FlexRender.Core/Layout/LayoutHelpers.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -229,8 +229,9 @@ internal static float CalculateTotalHeight(LayoutNode node)
229229
foreach (var child in node.Children)
230230
{
231231
if (child.Element.Position.Value == Position.Absolute) continue;
232-
if (child.Bottom > maxBottom)
233-
maxBottom = child.Bottom;
232+
var bottom = child.Bottom + child.MarginBottom;
233+
if (bottom > maxBottom)
234+
maxBottom = bottom;
234235
}
235236
return maxBottom;
236237
}
@@ -249,7 +250,7 @@ internal static float CalculateTotalWidth(LayoutNode node)
249250
foreach (var child in node.Children)
250251
{
251252
if (child.Element.Position.Value == Position.Absolute) continue;
252-
var right = child.X + child.Width;
253+
var right = child.X + child.Width + child.MarginRight;
253254
if (right > maxRight)
254255
maxRight = right;
255256
}

src/FlexRender.Core/Layout/LayoutNode.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using FlexRender.Layout.Units;
12
using FlexRender.Parsing.Ast;
23

34
namespace FlexRender.Layout;
@@ -67,6 +68,29 @@ public sealed class LayoutNode
6768
/// </summary>
6869
public LayoutDiagnostics? Diagnostics { get; set; }
6970

71+
/// <summary>
72+
/// The content inset (padding + border) for each side, in pixels. This is the distance
73+
/// from the element's box edge to its content area. Renderers subtract this inset when
74+
/// drawing leaf content so that padding insets content on all sides, while the background
75+
/// and border keep using the full box. Defaults to <see cref="PaddingValues.Zero"/>;
76+
/// container (flex) nodes leave it zero because their children are already offset by padding.
77+
/// </summary>
78+
public PaddingValues ContentInset { get; set; } = PaddingValues.Zero;
79+
80+
/// <summary>
81+
/// The resolved right (outer) margin in pixels. Used by auto-size calculations to include
82+
/// a trailing child's right margin in the parent's content extent. The left/top margins are
83+
/// already baked into <see cref="X"/>/<see cref="Y"/> by the flex strategies. Defaults to 0.
84+
/// </summary>
85+
public float MarginRight { get; set; }
86+
87+
/// <summary>
88+
/// The resolved bottom (outer) margin in pixels. Used by auto-size calculations to include
89+
/// a trailing child's bottom margin in the parent's content extent. The left/top margins are
90+
/// already baked into <see cref="X"/>/<see cref="Y"/> by the flex strategies. Defaults to 0.
91+
/// </summary>
92+
public float MarginBottom { get; set; }
93+
7094
/// <summary>Right edge (X + Width).</summary>
7195
public float Right => X + Width;
7296

src/FlexRender.Core/Layout/RowFlexLayoutStrategy.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,12 @@ internal static void LayoutRowFlex(LayoutNode node, FlexElement flex, LayoutCont
222222
pos += m.Left.IsAuto ? spacePerAuto : m.Left.ResolvedPixels;
223223
child.X = pos;
224224
pos += child.Width;
225-
pos += m.Right.IsAuto ? spacePerAuto : m.Right.ResolvedPixels;
225+
var autoRight = m.Right.IsAuto ? spacePerAuto : m.Right.ResolvedPixels;
226+
pos += autoRight;
227+
228+
// Store resolved outer right/bottom margins for trailing-margin auto-size.
229+
child.MarginRight = Math.Max(0f, autoRight);
230+
child.MarginBottom = Math.Max(0f, m.Bottom.IsAuto ? 0f : m.Bottom.ResolvedPixels);
226231

227232
// Cross axis auto margins override align-items (vertical for row)
228233
ApplyRowCrossAxisMargins(child, m, flex, padding, crossAxisSize, hasExplicitHeight);
@@ -302,6 +307,10 @@ internal static void LayoutRowFlex(LayoutNode node, FlexElement flex, LayoutCont
302307
var mTop = Math.Max(0f, m.Top.ResolvedPixels);
303308
var mBottom = Math.Max(0f, m.Bottom.ResolvedPixels);
304309

310+
// Store resolved outer right/bottom margins for trailing-margin auto-size.
311+
child.MarginRight = mRight;
312+
child.MarginBottom = mBottom;
313+
305314
// Add child margin to X position
306315
child.X = x + mLeft;
307316

src/FlexRender.Core/Layout/WrappedFlexLayoutStrategy.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,11 @@ private static void ResolveFlexForLine(LayoutNode node, FlexElement flex, Layout
511511
var child = lineChildren[i];
512512
var childMargin = PaddingParser.Parse(child.Element.Margin.Value, context.ContainerWidth, context.FontSize).ClampNegatives();
513513

514+
// Record resolved trailing margins so CalculateTotalHeight/Width (used for
515+
// auto-sizing the container) accounts for them, matching the non-wrap strategies.
516+
child.MarginRight = childMargin.Right;
517+
child.MarginBottom = childMargin.Bottom;
518+
514519
if (isColumn)
515520
{
516521
child.Y = pos + childMargin.Top;

src/FlexRender.ImageSharp.Render/Rendering/ImageSharpRenderingEngine.cs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -202,16 +202,22 @@ private void DrawElement(
202202
var effectiveFontSize = node.ComputedFontSize > 0 ? node.ComputedFontSize : _baseFontSize;
203203
var rotation = RotationHelper.ParseRotation(element.Rotate.Value);
204204

205+
// Content is inset by padding + border so it sits inside the box on all sides.
206+
// The background (drawn by the caller) keeps using the full box.
207+
var inset = node.ContentInset;
208+
var cw = Math.Max(0f, width - inset.Horizontal);
209+
var ch = Math.Max(0f, height - inset.Vertical);
210+
205211
if (RotationHelper.HasRotation(rotation))
206212
{
207213
DrawWithRotation(ctx, x, y, width, height, rotation, bufferCtx =>
208214
{
209-
DrawElementContent(bufferCtx, element, 0, 0, width, height, effectiveFontSize, imageCache);
215+
DrawElementContent(bufferCtx, element, inset.Left, inset.Top, cw, ch, effectiveFontSize, imageCache);
210216
});
211217
}
212218
else
213219
{
214-
DrawElementContent(ctx, element, x, y, width, height, effectiveFontSize, imageCache);
220+
DrawElementContent(ctx, element, x + inset.Left, y + inset.Top, cw, ch, effectiveFontSize, imageCache);
215221
}
216222
}
217223

src/FlexRender.Skia.Render/Rendering/RenderingEngine.cs

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -292,31 +292,39 @@ private void DrawElement(
292292
// Draw borders between background and content
293293
DrawBorders(canvas, element, x, y, width, height, borderRadius, effectiveFontSize);
294294

295+
// Content (text, bitmaps, shapes) is inset by padding + border so it sits inside the
296+
// box on all sides. Background and border above keep using the full box (x, y, w, h).
297+
var inset = node.ContentInset;
298+
var cx = x + inset.Left;
299+
var cy = y + inset.Top;
300+
var cw = Math.Max(0f, width - inset.Horizontal);
301+
var ch = Math.Max(0f, height - inset.Vertical);
302+
295303
switch (element)
296304
{
297305
case TextElement text:
298-
var bounds = new SKRect(x, y, x + width, y + height);
306+
var bounds = new SKRect(cx, cy, cx + cw, cy + ch);
299307
_textRenderer.DrawText(canvas, text, bounds, effectiveFontSize, renderOptions, direction, node.TextLines, node.ComputedLineHeight);
300308
break;
301309

302310
case QrElement qr when _qrProvider is not null:
303-
using (var bitmap = GetSkiaBitmap(_qrProvider, qr, (int)width, (int)height))
311+
using (var bitmap = GetSkiaBitmap(_qrProvider, qr, (int)cw, (int)ch))
304312
{
305-
DrawBitmapWithRotation(canvas, bitmap, element, x, y, width, height);
313+
DrawBitmapWithRotation(canvas, bitmap, element, cx, cy, cw, ch);
306314
}
307315
break;
308316

309317
case BarcodeElement barcode when _barcodeProvider is not null:
310-
using (var bitmap = GetSkiaBitmap(_barcodeProvider, barcode, (int)width, (int)height))
318+
using (var bitmap = GetSkiaBitmap(_barcodeProvider, barcode, (int)cw, (int)ch))
311319
{
312-
DrawBitmapWithRotation(canvas, bitmap, element, x, y, width, height);
320+
DrawBitmapWithRotation(canvas, bitmap, element, cx, cy, cw, ch);
313321
}
314322
break;
315323

316324
case SvgElement svg when _svgProvider is not null:
317-
using (var bitmap = GetSkiaBitmap(_svgProvider, svg, (int)width, (int)height))
325+
using (var bitmap = GetSkiaBitmap(_svgProvider, svg, (int)cw, (int)ch))
318326
{
319-
DrawBitmapWithRotation(canvas, bitmap, element, x, y, width, height);
327+
DrawBitmapWithRotation(canvas, bitmap, element, cx, cy, cw, ch);
320328
}
321329
break;
322330

@@ -325,38 +333,38 @@ private void DrawElement(
325333
image,
326334
imageCache,
327335
renderOptions.Antialiasing,
328-
layoutWidth: (int)width,
329-
layoutHeight: (int)height))
336+
layoutWidth: (int)cw,
337+
layoutHeight: (int)ch))
330338
{
331-
DrawBitmapWithRotation(canvas, bitmap, element, x, y, width, height);
339+
DrawBitmapWithRotation(canvas, bitmap, element, cx, cy, cw, ch);
332340
}
333341
break;
334342

335343
case RectElement rect:
336-
ShapeRenderer.DrawRect(canvas, rect, x, y, width, height, effectiveFontSize, renderOptions.Antialiasing);
344+
ShapeRenderer.DrawRect(canvas, rect, cx, cy, cw, ch, effectiveFontSize, renderOptions.Antialiasing);
337345
break;
338346

339347
case CircleElement circle:
340-
ShapeRenderer.DrawCircle(canvas, circle, x, y, width, height, renderOptions.Antialiasing);
348+
ShapeRenderer.DrawCircle(canvas, circle, cx, cy, cw, ch, renderOptions.Antialiasing);
341349
break;
342350

343351
case EllipseElement ellipse:
344-
ShapeRenderer.DrawEllipse(canvas, ellipse, x, y, width, height, renderOptions.Antialiasing);
352+
ShapeRenderer.DrawEllipse(canvas, ellipse, cx, cy, cw, ch, renderOptions.Antialiasing);
345353
break;
346354

347355
case DrawElement drawEl:
348-
ShapeRenderer.DrawShapes(canvas, drawEl, x, y, width, height, renderOptions.Antialiasing);
356+
ShapeRenderer.DrawShapes(canvas, drawEl, cx, cy, cw, ch, renderOptions.Antialiasing);
349357
break;
350358

351359
case ChartElement chart:
352360
ChartRenderer.Draw(
353-
canvas, chart, x, y, width, height,
361+
canvas, chart, cx, cy, cw, ch,
354362
_fontManager?.GetTypeface("main"),
355363
renderOptions.Antialiasing);
356364
break;
357365

358366
case SeparatorElement separator:
359-
DrawSeparator(canvas, separator, x, y, width, height, renderOptions.Antialiasing);
367+
DrawSeparator(canvas, separator, cx, cy, cw, ch, renderOptions.Antialiasing);
360368
break;
361369

362370
case FlexElement:

0 commit comments

Comments
 (0)