Repository: Kozea/WeasyPrint

Issue:
Incorrect width calculations for flex children
In a flex container with flex-direction: column, even if the children take up the full width by default, their own children (the grandchildren) cannot accurately determine their real width.

It worked fine in version 64.1, but is broken in version 65.1

Broken example
```
<section class="flex flex-col h-[256mm]">
  <div class="flex flex-1 flex-col justify-center bg-yellow-50">
    <p class="mt-6 text-center bg-red-50">
      <strong>Created on:</strong> April
    </p>
  </div>
</section>
```

<img width="796" alt="Image" src="https://github.com/user-attachments/assets/6a9b10f2-0c62-4723-ac60-c039b0bd2a5e" />

Specifying width: 100% (w-full) resolves the issue and the layout behaves as expected.
```
<section class="flex flex-col h-[256mm]">
  <div class="flex flex-1 flex-col justify-center bg-yellow-50 w-full">
    <p class="mt-6 text-center bg-red-50">
      <strong>Created on:</strong> April
    </p>
  </div>
</section>
```

<img width="795" alt="Image" src="https://github.com/user-attachments/assets/6ea61055-6781-4761-9bd9-0decc8a4ceae" />
Bottom margin on flex item ignored at the bottom of flex containers
Here’s a small example:

```html
<section style="display: flex; flex-direction: column /* or row */">
  <div style="background: pink">
    <p style="margin: 1em 0; background: yellow">
      A
    </p>
  </div>
</section>
```

(Discovered while solving #2442.)


Discussion:
Thanks for the example, I can reproduce.
This is caused by this TODO:

https://github.com/Kozea/WeasyPrint/blob/3e9f9b9af00c585a775c893390d4a3a2b3c468c6/weasyprint/layout/flex.py#L818-L820

As removing this ugly line breaks none of the 223 tests for flex, it’s very tempting to just remove it 😄. I’ll dig to find when it’s been added and if it’s not required anymore, but that would be awesome to close this issue by just removing a dirty piece of code with its associated TODO! (And add a 224th flex test, of course!)
Looks like it’s not required anymore. I’ll put the fix (and hopefully the one for #2449) in a separate branch and check it against the W3C test suite before merging.
@liZe Really appreciate the fix — great job!





Patch:
diff --git a/weasyprint/formatting_structure/build.py b/weasyprint/formatting_structure/build.py
index 237e88af4..fc234a9fb 100644
--- a/weasyprint/formatting_structure/build.py
+++ b/weasyprint/formatting_structure/build.py
@@ -983,6 +983,25 @@ def wrap_table(box, children):
     return wrapper
 
 
+def blockify(box, layout):
+    """Turn an inline box into a block box."""
+    # See https://drafts.csswg.org/css-display-4/#blockify.
+    if isinstance(box, boxes.InlineBlockBox):
+        anonymous = boxes.BlockBox.anonymous_from(box, box.children)
+    elif isinstance(box, boxes.InlineReplacedBox):
+        replacement = box.replacement
+        anonymous = boxes.BlockReplacedBox.anonymous_from(box, replacement)
+    elif isinstance(box, boxes.InlineLevelBox):
+        anonymous = boxes.BlockBox.anonymous_from(box, [box])
+        setattr(box, f'is_{layout}_item', False)
+    else:
+        return box
+    anonymous.style = box.style
+    setattr(anonymous, f'is_{layout}_item', True)
+    return anonymous
+
+
+
 def flex_boxes(box):
     """Remove and add boxes according to the flex model.
 
@@ -1012,18 +1031,7 @@ def flex_children(box, children):
                 # affected by the white-space property"
                 # https://www.w3.org/TR/css-flexbox-1/#flex-items
                 continue
-            if isinstance(child, boxes.InlineBlockBox):
-                anonymous = boxes.BlockBox.anonymous_from(child, child.children)
-                anonymous.style = child.style
-                anonymous.is_flex_item = True
-                flex_children.append(anonymous)
-            elif isinstance(child, boxes.InlineLevelBox):
-                anonymous = boxes.BlockBox.anonymous_from(child, [child])
-                anonymous.style = child.style
-                anonymous.is_flex_item = True
-                flex_children.append(anonymous)
-            else:
-                flex_children.append(child)
+            flex_children.append(blockify(child, 'flex'))
         return flex_children
     else:
         return children
@@ -1057,19 +1065,7 @@ def grid_children(box, children):
                 # affected by the white-space property"
                 # https://drafts.csswg.org/css-grid-2/#grid-item
                 continue
-            if isinstance(child, boxes.InlineBlockBox):
-                anonymous = boxes.BlockBox.anonymous_from(child, child.children)
-                anonymous.style = child.style
-                anonymous.is_grid_item = True
-                grid_children.append(anonymous)
-            elif isinstance(child, boxes.InlineLevelBox):
-                anonymous = boxes.BlockBox.anonymous_from(child, [child])
-                anonymous.style = child.style
-                child.is_grid_item = False
-                anonymous.is_grid_item = True
-                grid_children.append(anonymous)
-            else:
-                grid_children.append(child)
+            grid_children.append(blockify(child, 'grid'))
         return grid_children
     else:
         return children
diff --git a/weasyprint/layout/flex.py b/weasyprint/layout/flex.py
index f91b0bb84..73e7f619f 100644
--- a/weasyprint/layout/flex.py
+++ b/weasyprint/layout/flex.py
@@ -172,6 +172,8 @@ def flex_layout(context, box, bottom_space, skip_stack, containing_block, page_i
                     child.style['image_resolution'], child.style['font_size'])
                 if intrinsic_ratio and intrinsic_height:
                     transferred_size = intrinsic_height * intrinsic_ratio
+                    content_size = max(
+                        child.min_width, min(child.max_width, content_size))
             if specified_size != 'auto':
                 child.min_width = min(specified_size, content_size)
             elif transferred_size is not None:
@@ -183,12 +185,12 @@ def flex_layout(context, box, bottom_space, skip_stack, containing_block, page_i
             specified_size = child.height
             new_child = child.copy()
             new_child.style = child.style.copy()
-            if new_child.style['width'] == 'auto':
-                new_child_width = max_content_width(context, new_child)
-                new_child.style['width'] = Dimension(new_child_width, 'px')
             new_child.style['height'] = 'auto'
             new_child.style['min_height'] = Dimension(0, 'px')
             new_child.style['max_height'] = Dimension(inf, 'px')
+            if new_child.style['width'] == 'auto':
+                new_child_width = max_content_width(context, new_child)
+                new_child.style['width'] = Dimension(new_child_width, 'px')
             new_child = block.block_level_layout(
                 context, new_child, bottom_space, child_skip_stack, parent_box,
                 page_is_empty)[0]
@@ -200,6 +202,12 @@ def flex_layout(context, box, bottom_space, skip_stack, containing_block, page_i
                     child.style['image_resolution'], child.style['font_size'])
                 if intrinsic_ratio and intrinsic_width:
                     transferred_size = intrinsic_width / intrinsic_ratio
+                    content_size = max(
+                        child.min_height, min(child.max_height, content_size))
+                elif not intrinsic_width:
+                    # TODO: wrongly set by block_level_layout, would be OK with
+                    # min_content_height.
+                    content_size = 0
             if specified_size != 'auto':
                 child.min_height = min(specified_size, content_size)
             elif transferred_size is not None:
@@ -242,17 +250,31 @@ def flex_layout(context, box, bottom_space, skip_stack, containing_block, page_i
             pass
         else:
             # 3.E Otherwise…
+            new_child = child.copy()
+            new_child.style = child.style.copy()
             if main == 'width':
-                child.flex_base_size = max_content_width(context, child, outer=False)
+                # … the item’s min and max main sizes are ignored.
+                new_child.style['min_width'] = Dimension(0, 'px')
+                new_child.style['max_width'] = Dimension(inf, 'px')
+
+                child.flex_base_size = max_content_width(
+                    context, new_child, outer=False)
                 child.main_outer_extra = (
                     max_content_width(context, child) - child.flex_base_size)
             else:
-                new_child = child.copy()
+                # … the item’s min and max main sizes are ignored.
+                new_child.style['min_height'] = Dimension(0, 'px')
+                new_child.style['max_height'] = Dimension(inf, 'px')
+
                 new_child.width = inf
-                new_child = block.block_level_layout(
+                new_child, _, _, adjoining_margins, _, _ = block.block_level_layout(
                     context, new_child, bottom_space, child_skip_stack, parent_box,
-                    page_is_empty, absolute_boxes, fixed_boxes)[0]
+                    page_is_empty, absolute_boxes, fixed_boxes)
                 if new_child:
+                    # As flex items margins never collapse (with other flex items or
+                    # with the flex container), we can add the adjoining margins to the
+                    # child height.
+                    new_child.height += block.collapse_margin(adjoining_margins)
                     child.flex_base_size = new_child.height
                     child.main_outer_extra = (
                         new_child.margin_height() - new_child.height)
@@ -477,8 +499,8 @@ def flex_layout(context, box, bottom_space, skip_stack, containing_block, page_i
                 child.height = new_child.height
                 # As flex items margins never collapse (with other flex items or
                 # with the flex container), we can add the adjoining margins to the
-                # child bottom margin.
-                child.margin_bottom += block.collapse_margin(adjoining_margins)
+                # child height.
+                child.height += block.collapse_margin(adjoining_margins)
             else:
                 if child.width == 'auto':
                     min_width = min_content_width(context, child, outer=False)
@@ -591,9 +613,9 @@ def flex_layout(context, box, bottom_space, skip_stack, containing_block, page_i
                 align_self = align_items
             if 'stretch' in align_self and child.style[cross] == 'auto':
                 cross_margins = (
-                    (child.margin_top, child.margin_bottom)
-                    if cross == 'height'
-                    else (child.margin_left, child.margin_right))
+                    (child.style['margin_top'], child.style['margin_bottom'])
+                    if cross == 'height' else
+                    (child.style['margin_left'], child.style['margin_right']))
                 if 'auto' not in cross_margins:
                     cross_size = line.cross_size
                     if cross == 'height':
@@ -733,8 +755,9 @@ def flex_layout(context, box, bottom_space, skip_stack, containing_block, page_i
             line.lower_baseline = line[0][1]._baseline if line else 0
         for index, child in line:
             cross_margins = (
-                (child.margin_top, child.margin_bottom) if cross == 'height'
-                else (child.margin_left, child.margin_right))
+                (child.style['margin_top'], child.style['margin_bottom'])
+                if cross == 'height' else
+                (child.style['margin_left'], child.style['margin_right']))
             auto_margins = sum([margin == 'auto' for margin in cross_margins])
             # If a flex item has auto cross-axis margins…
             if auto_margins:
@@ -755,14 +778,14 @@ def flex_layout(context, box, bottom_space, skip_stack, containing_block, page_i
                     # If its outer cross size is less than the cross size…
                     extra_cross /= auto_margins
                     if cross == 'height':
-                        if child.margin_top == 'auto':
+                        if child.style['margin_top'] == 'auto':
                             child.margin_top = extra_cross
-                        if child.margin_bottom == 'auto':
+                        if child.style['margin_bottom'] == 'auto':
                             child.margin_bottom = extra_cross
                     else:
-                        if child.margin_left == 'auto':
+                        if child.style['margin_left'] == 'auto':
                             child.margin_left = extra_cross
-                        if child.margin_right == 'auto':
+                        if child.style['margin_right'] == 'auto':
                             child.margin_right = extra_cross
                 else:
                     # Otherwise…
@@ -815,9 +838,6 @@ def flex_layout(context, box, bottom_space, skip_stack, containing_block, page_i
                                 margins += (
                                     child.border_left_width + child.border_right_width +
                                     child.padding_left + child.padding_right)
-                        # TODO: Don't set style width, find a way to avoid width
-                        # re-calculation after 16.
-                        child.style[cross] = Dimension(line.cross_size - margins, 'px')
         position_cross += line.cross_size
 
     # 15 Determine the flex container’s used cross size.


Test patch:
diff --git a/tests/layout/test_flex.py b/tests/layout/test_flex.py
index b4abfb9b8..bc839c930 100644
--- a/tests/layout/test_flex.py
+++ b/tests/layout/test_flex.py
@@ -965,6 +965,29 @@ def test_flex_item_overflow():
     assert text2.text == 'e'
 
 
+@assert_no_logs
+@pytest.mark.parametrize('direction', ('row', 'column'))
+def test_flex_item_child_bottom_margin(direction):
+    # Regression test for issue #2449.
+    page, = render_pages('''
+      <div style="display: flex; font: 2px weasyprint; flex-direction: %s">
+        <section>
+          <div style="margin: 2px 0">ab</div>
+        </section>
+      </div>''' % direction)
+    html, = page.children
+    body, = html.children
+    flex, = body.children
+    assert flex.content_box_y() == 0
+    assert flex.height == 6
+    section, = flex.children
+    assert section.content_box_y() == 0
+    assert section.height == 6
+    div, = section.children
+    assert div.content_box_y() == 2
+    assert div.height == 2
+
+
 @assert_no_logs
 def test_flex_direction_row_inline_block():
     # Regression test for issue #1652.
@@ -1110,8 +1133,6 @@ def test_flex_item_auto_margin_main():
 
 @assert_no_logs
 def test_flex_item_auto_margin_cross():
-    # TODO: we keep this test because it used to crash, but we have to fix the top
-    # margin.
     page, = render_pages('''
       <article style="display: flex; height: 100px">
         <div style="margin-top: auto; height: 10px; width: 10px"></div>
@@ -1123,7 +1144,7 @@ def test_flex_item_auto_margin_cross():
     div, = article.children
     assert div.height == 10
     assert div.width == 10
-    # assert div.margin_top == 90
+    assert div.margin_top == 90
 
 
 @assert_no_logs
@@ -1831,3 +1852,80 @@ def test_flex_item_replaced():
     div, = body.children
     svg, = div.children
     assert svg.width == svg.height == 100
+
+
+@assert_no_logs
+def test_flex_nested_column():
+    # Regression test for issue #2442.
+    page, = render_pages('''
+      <section style="display: flex; flex-direction: column; width: 200px">
+        <div style="display: flex; flex-direction: column">
+          <p>
+            A
+          </p>
+        </div>
+      </section>
+    ''')
+    html, = page.children
+    body, = html.children
+    section, = body.children
+    div, = section.children
+    p, = div.children
+    assert p.width == 200
+
+
+@assert_no_logs
+def test_flex_blockify_image():
+    page, = render_pages('''
+      <article style="display: flex; line-height: 2">
+        <img src="pattern.png">
+      </article>
+    ''')
+    html, = page.children
+    body, = html.children
+    article, = body.children
+    img, = article.children
+    assert article.height == img.height == 4
+
+
+@assert_no_logs
+def test_flex_image_max_width():
+    page, = render_pages('''
+      <article style="display: flex">
+        <img src="pattern.png" style="max-width: 2px">
+      </article>
+    ''')
+    html, = page.children
+    body, = html.children
+    article, = body.children
+    img, = article.children
+    assert article.height == img.height == img.width == 2
+
+
+@assert_no_logs
+def test_flex_image_max_height():
+    page, = render_pages('''
+      <article style="display: flex">
+        <img src="pattern.png" style="max-height: 2px">
+      </article>
+    ''')
+    html, = page.children
+    body, = html.children
+    article, = body.children
+    img, = article.children
+    assert article.height == img.height == img.width == 2
+
+
+@assert_no_logs
+def test_flex_image_min_width():
+    page, = render_pages('''
+      <article style="display: flex; width: 20px">
+        <img style="min-width: 10px; flex: 1 0 auto" src="pattern.png">
+        <div style="flex: 1 0 1px"></div>
+      </article>
+    ''')
+    html, = page.children
+    body, = html.children
+    article, = body.children
+    img, div = article.children
+    assert article.height == img.height == img.width == div.height == 10