Repository: Kozea/WeasyPrint
Pull request #941 (2019-09-11 15:19:00)

Page break table


Patch:
diff --git a/weasyprint/layout/blocks.py b/weasyprint/layout/blocks.py
index e7ea66eb..b7cb4eeb 100644
--- a/weasyprint/layout/blocks.py
+++ b/weasyprint/layout/blocks.py
@@ -723,8 +723,12 @@ def block_level_page_break(sibling_before, sibling_after):
 
     """
     values = []
+    # https://drafts.csswg.org/css-break-3/#possible-breaks
+    block_parallel_box_types = (
+        boxes.BlockLevelBox, boxes.TableRowGroupBox, boxes.TableRowBox)
+
     box = sibling_before
-    while isinstance(box, boxes.BlockLevelBox):
+    while isinstance(box, block_parallel_box_types):
         values.append(box.style['break_after'])
         if not (isinstance(box, boxes.ParentBox) and box.children):
             break
@@ -732,7 +736,7 @@ def block_level_page_break(sibling_before, sibling_after):
     values.reverse()  # Have them in tree order
 
     box = sibling_after
-    while isinstance(box, boxes.BlockLevelBox):
+    while isinstance(box, block_parallel_box_types):
         values.append(box.style['break_before'])
         if not (isinstance(box, boxes.ParentBox) and box.children):
             break
@@ -796,7 +800,9 @@ def find_earlier_page_break(children, absolute_boxes, fixed_boxes):
             previous_in_flow = child
         if child.is_in_normal_flow() and (
                 child.style['break_inside'] not in ('avoid', 'avoid-page')):
-            if isinstance(child, boxes.BlockBox):
+            breakable_box_types = (
+                boxes.BlockBox, boxes.TableBox, boxes.TableRowGroupBox)
+            if isinstance(child, breakable_box_types):
                 result = find_earlier_page_break(
                     child.children, absolute_boxes, fixed_boxes)
                 if result:
@@ -807,8 +813,6 @@ def find_earlier_page_break(children, absolute_boxes, fixed_boxes):
                     resume_at = (new_child.index, resume_at)
                     index += 1  # Remove placeholders after child
                     break
-            elif isinstance(child, boxes.TableBox):
-                pass  # TODO: find an earlier break between table rows.
     else:
         return None
 
diff --git a/weasyprint/layout/tables.py b/weasyprint/layout/tables.py
index 7d0b7891..23a9f109 100644
--- a/weasyprint/layout/tables.py
+++ b/weasyprint/layout/tables.py
@@ -19,7 +19,9 @@ def table_layout(context, table, max_position_y, skip_stack, containing_block,
                  page_is_empty, absolute_boxes, fixed_boxes):
     """Layout for a table box."""
     # Avoid a circular import
-    from .blocks import block_container_layout
+    from .blocks import (
+        block_container_layout, block_level_page_break,
+        find_earlier_page_break)
 
     column_widths = table.column_widths
 
@@ -61,6 +63,7 @@ def table_layout(context, table, max_position_y, skip_stack, containing_block,
     def group_layout(group, position_y, max_position_y,
                      page_is_empty, skip_stack):
         resume_at = None
+        next_page = {'break': 'any', 'page': None}
         original_page_is_empty = page_is_empty
         resolve_percentages(group, containing_block=table)
         group.position_x = rows_x
@@ -78,6 +81,16 @@ def table_layout(context, table, max_position_y, skip_stack, containing_block,
             assert not skip_stack  # No breaks inside rows for now
         for i, row in enumerate(group.children[skip:]):
             index_row = i + skip
+            row.index = index_row
+
+            if new_group_children:
+                page_break = block_level_page_break(
+                    new_group_children[-1], row)
+                if page_break in ('page', 'recto', 'verso', 'left', 'right'):
+                    next_page['break'] = page_break
+                    resume_at = (index_row, None)
+                    break
+
             resolve_percentages(row, containing_block=table)
             row.position_x = rows_x
             row.position_y = position_y
@@ -207,7 +220,22 @@ def table_layout(context, table, max_position_y, skip_stack, containing_block,
             # Break if this row overflows the page, unless there is no
             # other content on the page.
             if next_position_y > max_position_y and not page_is_empty:
-                resume_at = (index_row, None)
+                if new_group_children:
+                    previous_row = new_group_children[-1]
+                    page_break = block_level_page_break(previous_row, row)
+                    if page_break == 'avoid':
+                        earlier_page_break = find_earlier_page_break(
+                            new_group_children, absolute_boxes, fixed_boxes)
+                        if earlier_page_break:
+                            new_group_children, resume_at = earlier_page_break
+                            break
+                    else:
+                        resume_at = (index_row, None)
+                        break
+                if original_page_is_empty:
+                    resume_at = (index_row, None)
+                else:
+                    return None, None, next_page
                 break
 
             position_y = next_position_y
@@ -219,7 +247,7 @@ def table_layout(context, table, max_position_y, skip_stack, containing_block,
         if resume_at and not original_page_is_empty and (
                 group.style['break_inside'] in ('avoid', 'avoid-page') or
                 not new_group_children):
-            return None, None
+            return None, None, next_page
 
         group = group.copy_with_children(
             new_group_children,
@@ -240,7 +268,7 @@ def table_layout(context, table, max_position_y, skip_stack, containing_block,
             # The last border spacing is outside of the group.
             group.height -= border_spacing_y
 
-        return group, resume_at
+        return group, resume_at, next_page
 
     def body_groups_layout(skip_stack, position_y, max_position_y,
                            page_is_empty):
@@ -250,16 +278,40 @@ def table_layout(context, table, max_position_y, skip_stack, containing_block,
             skip, skip_stack = skip_stack
         new_table_children = []
         resume_at = None
+        next_page = {'break': 'any', 'page': None}
+
         for i, group in enumerate(table.children[skip:]):
             index_group = i + skip
+            group.index = index_group
+
             if group.is_header or group.is_footer:
                 continue
-            new_group, resume_at = group_layout(
+
+            if new_table_children:
+                page_break = block_level_page_break(
+                    new_table_children[-1], group)
+                if page_break in ('page', 'recto', 'verso', 'left', 'right'):
+                    next_page['break'] = page_break
+                    resume_at = (index_group, None)
+                    break
+
+            new_group, resume_at, next_page = group_layout(
                 group, position_y, max_position_y, page_is_empty, skip_stack)
             skip_stack = None
 
             if new_group is None:
-                resume_at = (index_group, None)
+                if new_table_children:
+                    previous_group = new_table_children[-1]
+                    page_break = block_level_page_break(previous_group, group)
+                    if page_break == 'avoid':
+                        earlier_page_break = find_earlier_page_break(
+                            new_table_children, absolute_boxes, fixed_boxes)
+                        if earlier_page_break is not None:
+                            new_table_children, resume_at = earlier_page_break
+                            break
+                    resume_at = (index_group, None)
+                else:
+                    return None, None, next_page, position_y
                 break
 
             new_table_children.append(new_group)
@@ -269,7 +321,8 @@ def table_layout(context, table, max_position_y, skip_stack, containing_block,
             if resume_at:
                 resume_at = (index_group, resume_at)
                 break
-        return new_table_children, resume_at, position_y
+
+        return new_table_children, resume_at, next_page, position_y
 
     # Layout for row groups, rows and cells
     position_y = table.content_box_y() + border_spacing_y
@@ -278,7 +331,7 @@ def table_layout(context, table, max_position_y, skip_stack, containing_block,
     def all_groups_layout():
         if table.children and table.children[0].is_header:
             header = table.children[0]
-            header, resume_at = group_layout(
+            header, resume_at, next_page = group_layout(
                 header, position_y, max_position_y,
                 skip_stack=None, page_is_empty=False)
             if header and not resume_at:
@@ -290,7 +343,7 @@ def table_layout(context, table, max_position_y, skip_stack, containing_block,
 
         if table.children and table.children[-1].is_footer:
             footer = table.children[-1]
-            footer, resume_at = group_layout(
+            footer, resume_at, next_page = group_layout(
                 footer, position_y, max_position_y,
                 skip_stack=None, page_is_empty=False)
             if footer and not resume_at:
@@ -311,54 +364,60 @@ def table_layout(context, table, max_position_y, skip_stack, containing_block,
 
         if header and footer:
             # Try with both the header and footer
-            new_table_children, resume_at, end_position_y = body_groups_layout(
-                skip_stack,
-                position_y=position_y + header_height,
-                max_position_y=max_position_y - footer_height,
-                page_is_empty=avoid_breaks)
+            new_table_children, resume_at, next_page, end_position_y = (
+                body_groups_layout(
+                    skip_stack,
+                    position_y=position_y + header_height,
+                    max_position_y=max_position_y - footer_height,
+                    page_is_empty=avoid_breaks))
             if new_table_children or not page_is_empty:
                 footer.translate(dy=end_position_y - footer.position_y)
                 end_position_y += footer_height
                 return (header, new_table_children, footer,
-                        end_position_y, resume_at)
+                        end_position_y, resume_at, next_page)
             else:
                 # We could not fit any content, drop the footer
                 footer = None
 
         if header and not footer:
             # Try with just the header
-            new_table_children, resume_at, end_position_y = body_groups_layout(
-                skip_stack,
-                position_y=position_y + header_height,
-                max_position_y=max_position_y,
-                page_is_empty=avoid_breaks)
+            new_table_children, resume_at, next_page, end_position_y = (
+                body_groups_layout(
+                    skip_stack,
+                    position_y=position_y + header_height,
+                    max_position_y=max_position_y,
+                    page_is_empty=avoid_breaks))
             if new_table_children or not page_is_empty:
                 return (header, new_table_children, footer,
-                        end_position_y, resume_at)
+                        end_position_y, resume_at, next_page)
             else:
                 # We could not fit any content, drop the header
                 header = None
 
         if footer and not header:
             # Try with just the footer
-            new_table_children, resume_at, end_position_y = body_groups_layout(
-                skip_stack,
-                position_y=position_y,
-                max_position_y=max_position_y - footer_height,
-                page_is_empty=avoid_breaks)
+            new_table_children, resume_at, next_page, end_position_y = (
+                body_groups_layout(
+                    skip_stack,
+                    position_y=position_y,
+                    max_position_y=max_position_y - footer_height,
+                    page_is_empty=avoid_breaks))
             if new_table_children or not page_is_empty:
                 footer.translate(dy=end_position_y - footer.position_y)
                 end_position_y += footer_height
                 return (header, new_table_children, footer,
-                        end_position_y, resume_at)
+                        end_position_y, resume_at, next_page)
             else:
                 # We could not fit any content, drop the footer
                 footer = None
 
         assert not (header or footer)
-        new_table_children, resume_at, end_position_y = body_groups_layout(
-            skip_stack, position_y, max_position_y, page_is_empty)
-        return header, new_table_children, footer, end_position_y, resume_at
+        new_table_children, resume_at, next_page, end_position_y = (
+            body_groups_layout(
+                skip_stack, position_y, max_position_y, page_is_empty))
+        return (
+            header, new_table_children, footer, end_position_y, resume_at,
+            next_page)
 
     def get_column_cells(table, column):
         """Closure getting the column cells."""
@@ -369,8 +428,17 @@ def table_layout(context, table, max_position_y, skip_stack, containing_block,
             for cell in row.children
             if cell.grid_x == column.grid_x]
 
-    header, new_table_children, footer, position_y, resume_at = \
+    header, new_table_children, footer, position_y, resume_at, next_page = \
         all_groups_layout()
+
+    if new_table_children is None:
+        assert resume_at is None
+        table = None
+        adjoining_margins = []
+        collapsing_through = False
+        return (
+            table, resume_at, next_page, adjoining_margins, collapsing_through)
+
     table = table.copy_with_children(
         ([header] if header is not None else []) +
         new_table_children +
@@ -413,10 +481,8 @@ def table_layout(context, table, max_position_y, skip_stack, containing_block,
         group.width = last.position_x + last.width - first.position_x
         group.height = columns_height
 
-    next_page = {'break': 'any', 'page': table.style['page']}
     if resume_at and not page_is_empty and (
-            table.style['break_inside'] in ('avoid', 'avoid-page') or
-            not new_table_children):
+            table.style['break_inside'] in ('avoid', 'avoid-page')):
         table = None
         resume_at = None
     adjoining_margins = []


Test patch:
diff --git a/weasyprint/tests/test_layout/test_table.py b/weasyprint/tests/test_layout/test_table.py
index 6b2b8786..64c874b2 100644
--- a/weasyprint/tests/test_layout/test_table.py
+++ b/weasyprint/tests/test_layout/test_table.py
@@ -2088,6 +2088,390 @@ def test_table_page_breaks_complex():
     ]
 
 
+@assert_no_logs
+def test_table_page_break_after():
+    page1, page2, page3, page4, page5, page6 = render_pages('''
+      <style>
+        @page { size: 1000px }
+        h1 { height: 30px}
+        td { height: 40px }
+        table { table-layout: fixed; width: 100% }
+      </style>
+      <h1>Dummy title</h1>
+      <table>
+
+        <tbody>
+          <tr><td>row 1</td></tr>
+          <tr><td>row 2</td></tr>
+          <tr><td>row 3</td></tr>
+        </tbody>
+        <tbody>
+          <tr style="break-after: page"><td>row 1</td></tr>
+          <tr><td>row 2</td></tr>
+          <tr><td>row 3</td></tr>
+        </tbody>
+        <tbody>
+          <tr><td>row 1</td></tr>
+          <tr><td>row 2</td></tr>
+          <tr style="break-after: page"><td>row 3</td></tr>
+        </tbody>
+        <tbody style="break-after: right">
+          <tr><td>row 1</td></tr>
+          <tr><td>row 2</td></tr>
+          <tr><td>row 3</td></tr>
+        </tbody>
+        <tbody style="break-after: page">
+          <tr><td>row 1</td></tr>
+          <tr><td>row 2</td></tr>
+          <tr><td>row 3</td></tr>
+        </tbody>
+
+      </table>
+      <p>bla bla</p>
+     ''')
+    html, = page1.children
+    body, = html.children
+    h1, table_wrapper = body.children
+    table, = table_wrapper.children
+    table_group1, table_group2 = table.children
+    assert len(table_group1.children) == 3
+    assert len(table_group2.children) == 1
+
+    html, = page2.children
+    body, = html.children
+    table_wrapper, = body.children
+    table, = table_wrapper.children
+    table_group1, table_group2 = table.children
+    assert len(table_group1.children) == 2
+    assert len(table_group2.children) == 3
+
+    html, = page3.children
+    body, = html.children
+    table_wrapper, = body.children
+    table, = table_wrapper.children
+    table_group, = table.children
+    assert len(table_group.children) == 3
+
+    html, = page4.children
+    assert not html.children
+
+    html, = page5.children
+    body, = html.children
+    table_wrapper, = body.children
+    table, = table_wrapper.children
+    table_group, = table.children
+    assert len(table_group.children) == 3
+
+    html, = page6.children
+    body, = html.children
+    p, = body.children
+    assert p.element_tag == 'p'
+
+
+@assert_no_logs
+def test_table_page_break_before():
+    page1, page2, page3, page4, page5, page6 = render_pages('''
+      <style>
+        @page { size: 1000px }
+        h1 { height: 30px}
+        td { height: 40px }
+        table { table-layout: fixed; width: 100% }
+      </style>
+      <h1>Dummy title</h1>
+      <table>
+
+        <tbody>
+          <tr style="break-before: page"><td>row 1</td></tr>
+          <tr><td>row 2</td></tr>
+          <tr><td>row 3</td></tr>
+        </tbody>
+        <tbody>
+          <tr><td>row 1</td></tr>
+          <tr style="break-before: page"><td>row 2</td></tr>
+          <tr><td>row 3</td></tr>
+        </tbody>
+        <tbody>
+          <tr style="break-before: page"><td>row 1</td></tr>
+          <tr><td>row 2</td></tr>
+          <tr><td>row 3</td></tr>
+        </tbody>
+        <tbody>
+          <tr><td>row 1</td></tr>
+          <tr><td>row 2</td></tr>
+          <tr><td>row 3</td></tr>
+        </tbody>
+        <tbody style="break-before: left">
+          <tr><td>row 1</td></tr>
+          <tr><td>row 2</td></tr>
+          <tr><td>row 3</td></tr>
+        </tbody>
+
+      </table>
+      <p>bla bla</p>
+     ''')
+    html, = page1.children
+    body, = html.children
+    h1, = body.children
+    assert h1.element_tag == 'h1'
+
+    html, = page2.children
+    body, = html.children
+    table_wrapper, = body.children
+    table, = table_wrapper.children
+    table_group1, table_group2 = table.children
+    assert len(table_group1.children) == 3
+    assert len(table_group2.children) == 1
+
+    html, = page3.children
+    body, = html.children
+    table_wrapper, = body.children
+    table, = table_wrapper.children
+    table_group, = table.children
+    assert len(table_group.children) == 2
+
+    html, = page4.children
+    body, = html.children
+    table_wrapper, = body.children
+    table, = table_wrapper.children
+    table_group1, table_group2 = table.children
+    assert len(table_group1.children) == 3
+    assert len(table_group2.children) == 3
+
+    html, = page5.children
+    assert not html.children
+
+    html, = page6.children
+    body, = html.children
+    table_wrapper, p = body.children
+    table, = table_wrapper.children
+    table_group, = table.children
+    assert len(table_group.children) == 3
+    assert p.element_tag == 'p'
+
+
+@assert_no_logs
+@pytest.mark.parametrize('html, rows', (
+    ('''
+      <style>
+        @page { size: 100px }
+        table { table-layout: fixed; width: 100% }
+        tr { height: 26px }
+      </style>
+      <table>
+        <tbody>
+          <tr><td>row 0</td></tr>
+          <tr><td>row 1</td></tr>
+          <tr style="break-before: avoid"><td>row 2</td></tr>
+          <tr style="break-before: avoid"><td>row 3</td></tr>
+        </tbody>
+      </table>
+    ''',
+     [1, 3]),
+    ('''
+      <style>
+        @page { size: 100px }
+        table { table-layout: fixed; width: 100% }
+        tr { height: 26px }
+      </style>
+      <table>
+        <tbody>
+          <tr><td>row 0</td></tr>
+          <tr style="break-after: avoid"><td>row 1</td></tr>
+          <tr><td>row 2</td></tr>
+          <tr style="break-before: avoid"><td>row 3</td></tr>
+        </tbody>
+      </table>
+     ''',
+     [1, 3]),
+    ('''
+      <style>
+        @page { size: 100px }
+        table { table-layout: fixed; width: 100% }
+        tr { height: 26px }
+      </style>
+      <table>
+        <tbody>
+          <tr><td>row 0</td></tr>
+          <tr><td>row 1</td></tr>
+          <tr style="break-after: avoid"><td>row 2</td></tr>
+          <tr><td>row 3</td></tr>
+        </tbody>
+      </table>
+     ''',
+     [2, 2]),
+    ('''
+      <style>
+        @page { size: 100px }
+        table { table-layout: fixed; width: 100% }
+        tr { height: 26px }
+      </style>
+      <table>
+        <tbody>
+          <tr style="break-before: avoid"><td>row 0</td></tr>
+          <tr style="break-before: avoid"><td>row 1</td></tr>
+          <tr style="break-before: avoid"><td>row 2</td></tr>
+          <tr style="break-before: avoid"><td>row 3</td></tr>
+        </tbody>
+      </table>
+     ''',
+     [3, 1]),
+    ('''
+      <style>
+        @page { size: 100px }
+        table { table-layout: fixed; width: 100% }
+        tr { height: 26px }
+      </style>
+      <table>
+        <tbody>
+          <tr style="break-after: avoid"><td>row 0</td></tr>
+          <tr style="break-after: avoid"><td>row 1</td></tr>
+          <tr style="break-after: avoid"><td>row 2</td></tr>
+          <tr style="break-after: avoid"><td>row 3</td></tr>
+        </tbody>
+      </table>
+     ''',
+     [3, 1]),
+    ('''
+      <style>
+        @page { size: 100px }
+        table { table-layout: fixed; width: 100% }
+        tr { height: 26px }
+        p { height: 26px }
+      </style>
+      <p>wow p</p>
+      <table>
+        <tbody>
+          <tr style="break-after: avoid"><td>row 0</td></tr>
+          <tr style="break-after: avoid"><td>row 1</td></tr>
+          <tr style="break-after: avoid"><td>row 2</td></tr>
+          <tr style="break-after: avoid"><td>row 3</td></tr>
+        </tbody>
+      </table>
+     ''',
+     [1, 3, 1]),
+    ('''
+      <style>
+        @page { size: 100px }
+        table { table-layout: fixed; width: 100% }
+        tr { height: 30px }
+      </style>
+      <table>
+        <tbody style="break-after: avoid">
+          <tr><td>row 0</td></tr>
+          <tr><td>row 1</td></tr>
+          <tr><td>row 2</td></tr>
+        </tbody>
+        <tbody>
+          <tr><td>row 0</td></tr>
+          <tr><td>row 1</td></tr>
+          <tr><td>row 2</td></tr>
+        </tbody>
+      </table>
+     ''',
+     [2, 3, 1]),
+    ('''
+      <style>
+        @page { size: 100px }
+        table { table-layout: fixed; width: 100% }
+        tr { height: 30px }
+      </style>
+      <table>
+        <tbody>
+          <tr><td>row 0</td></tr>
+          <tr><td>row 1</td></tr>
+          <tr><td>row 2</td></tr>
+        </tbody>
+        <tbody style="break-before: avoid">
+          <tr><td>row 0</td></tr>
+          <tr><td>row 1</td></tr>
+          <tr><td>row 2</td></tr>
+        </tbody>
+      </table>
+     ''',
+     [2, 3, 1]),
+    ('''
+      <style>
+        @page { size: 100px }
+        table { table-layout: fixed; width: 100% }
+        tr { height: 30px }
+      </style>
+      <table>
+        <tbody>
+          <tr><td>row 0</td></tr>
+          <tr><td>row 1</td></tr>
+          <tr><td>row 2</td></tr>
+        </tbody>
+        <tbody>
+          <tr style="break-before: avoid"><td>row 0</td></tr>
+          <tr><td>row 1</td></tr>
+          <tr><td>row 2</td></tr>
+        </tbody>
+      </table>
+     ''',
+     [2, 3, 1]),
+    ('''
+      <style>
+        @page { size: 100px }
+        table { table-layout: fixed; width: 100% }
+        tr { height: 30px }
+      </style>
+      <table>
+        <tbody>
+          <tr><td>row 0</td></tr>
+          <tr><td>row 1</td></tr>
+          <tr style="break-after: avoid"><td>row 2</td></tr>
+        </tbody>
+        <tbody>
+          <tr><td>row 0</td></tr>
+          <tr><td>row 1</td></tr>
+          <tr><td>row 2</td></tr>
+        </tbody>
+      </table>
+     ''',
+     [2, 3, 1]),
+    ('''
+      <style>
+        @page { size: 100px }
+        table { table-layout: fixed; width: 100% }
+        tr { height: 30px }
+      </style>
+      <table>
+        <tbody style="break-after: avoid">
+          <tr><td>row 0</td></tr>
+          <tr><td>row 1</td></tr>
+          <tr style="break-after: page"><td>row 2</td></tr>
+        </tbody>
+        <tbody>
+          <tr><td>row 0</td></tr>
+          <tr><td>row 1</td></tr>
+          <tr><td>row 2</td></tr>
+        </tbody>
+      </table>
+     ''',
+     [3, 3]),
+))
+def test_table_page_break_avoid(html, rows):
+    pages = render_pages(html)
+    assert len(pages) == len(rows)
+    rows_per_page = []
+    for page in pages:
+        html, = page.children
+        body, = html.children
+        if body.children[0].element_tag == 'p':
+            rows_per_page.append(len(body.children))
+            continue
+        else:
+            table_wrapper, = body.children
+        table, = table_wrapper.children
+        rows_in_this_page = 0
+        for group in table.children:
+            for row in group.children:
+                rows_in_this_page += 1
+        rows_per_page.append(rows_in_this_page)
+
+    assert rows_per_page == rows
+
+
 @assert_no_logs
 @pytest.mark.parametrize('vertical_align, table_position_y', (
     ('top', 8),