Repository: Kozea/WeasyPrint
Pull request #1884 (2023-05-21 08:35:10)

Propose fix for issue #1278
Before these properties are overwritten in the `set_transparent_border` function:
- `border_{side}_style`
- `border_{side}_width`
- `border_{side}_color`

a copy of the original value is stored in the `style` dictionary using property name prefixed with double underscore `__`:
- `__border_{side}_style`
- `__border_{side}_width`
- `__border_{side}_color`

When `collapse_table_border` is called each time the boxes of the table are built, the original property values are restored first, if exists.


Patch:
diff --git a/weasyprint/formatting_structure/build.py b/weasyprint/formatting_structure/build.py
index 8b1553d3..526da1d9 100644
--- a/weasyprint/formatting_structure/build.py
+++ b/weasyprint/formatting_structure/build.py
@@ -7,10 +7,9 @@ This includes creating anonymous boxes and processing whitespace as necessary.
 import re
 import unicodedata
 
-import tinycss2.color3
-
 from .. import html
 from ..css import properties, targets
+from ..layout.table import collapse_table_borders
 from ..logger import LOGGER
 from . import boxes
 
@@ -995,162 +994,6 @@ def wrap_table(box, children):
     return wrapper
 
 
-TRANSPARENT = tinycss2.color3.parse_color('transparent')
-
-
-def collapse_table_borders(table, grid_width, grid_height):
-    """Resolve border conflicts for a table in the collapsing border model.
-
-    Take a :class:`TableBox`; set appropriate border widths on the table,
-    column group, column, row group, row, and cell boxes; and return
-    a data structure for the resolved collapsed border grid.
-
-    """
-    if not (grid_width and grid_height):
-        # Don’t bother with empty tables
-        return [], []
-
-    styles = reversed([
-        'hidden', 'double', 'solid', 'dashed', 'dotted', 'ridge', 'outset',
-        'groove', 'inset', 'none'])
-    style_scores = {style: score for score, style in enumerate(styles)}
-    style_map = {'inset': 'ridge', 'outset': 'groove'}
-    weak_null_border = (
-        (0, 0, style_scores['none']), ('none', 0, TRANSPARENT))
-    vertical_borders = [
-        [weak_null_border] * (grid_width + 1) for _ in range(grid_height)]
-    horizontal_borders = [
-        [weak_null_border] * grid_width for _ in range(grid_height + 1)]
-
-    def set_one_border(border_grid, box_style, side, grid_x, grid_y):
-        from ..draw import get_color
-
-        style = box_style[f'border_{side}_style']
-        width = box_style[f'border_{side}_width']
-        color = get_color(box_style, f'border_{side}_color')
-
-        # https://www.w3.org/TR/CSS21/tables.html#border-conflict-resolution
-        score = ((1 if style == 'hidden' else 0), width, style_scores[style])
-
-        style = style_map.get(style, style)
-        previous_score, _ = border_grid[grid_y][grid_x]
-        # Strict < so that the earlier call wins in case of a tie.
-        if previous_score < score:
-            border_grid[grid_y][grid_x] = (score, (style, width, color))
-
-    def set_borders(box, x, y, w, h):
-        style = box.style
-        for yy in range(y, y + h):
-            set_one_border(vertical_borders, style, 'left', x, yy)
-            set_one_border(vertical_borders, style, 'right', x + w, yy)
-        for xx in range(x, x + w):
-            set_one_border(horizontal_borders, style, 'top', xx, y)
-            set_one_border(horizontal_borders, style, 'bottom', xx, y + h)
-
-    # The order is important here:
-    # "A style set on a cell wins over one on a row, which wins over a
-    #  row group, column, column group and, lastly, table"
-    # See https://www.w3.org/TR/CSS21/tables.html#border-conflict-resolution
-    strong_null_border = (
-        (1, 0, style_scores['hidden']), ('hidden', 0, TRANSPARENT))
-    grid_y = 0
-    for row_group in table.children:
-        for row in row_group.children:
-            for cell in row.children:
-                # No border inside of a cell with rowspan or colspan
-                for xx in range(cell.grid_x + 1, cell.grid_x + cell.colspan):
-                    for yy in range(grid_y, grid_y + cell.rowspan):
-                        vertical_borders[yy][xx] = strong_null_border
-                for xx in range(cell.grid_x, cell.grid_x + cell.colspan):
-                    for yy in range(grid_y + 1, grid_y + cell.rowspan):
-                        horizontal_borders[yy][xx] = strong_null_border
-                # The cell’s own borders
-                set_borders(cell, x=cell.grid_x, y=grid_y,
-                            w=cell.colspan, h=cell.rowspan)
-            grid_y += 1
-
-    grid_y = 0
-    for row_group in table.children:
-        for row in row_group.children:
-            set_borders(row, x=0, y=grid_y, w=grid_width, h=1)
-            grid_y += 1
-
-    grid_y = 0
-    for row_group in table.children:
-        rowspan = len(row_group.children)
-        set_borders(row_group, x=0, y=grid_y, w=grid_width, h=rowspan)
-        grid_y += rowspan
-
-    for column_group in table.column_groups:
-        for column in column_group.children:
-            set_borders(column, x=column.grid_x, y=0, w=1, h=grid_height)
-
-    for column_group in table.column_groups:
-        set_borders(column_group, x=column_group.grid_x, y=0,
-                    w=column_group.span, h=grid_height)
-
-    set_borders(table, x=0, y=0, w=grid_width, h=grid_height)
-
-    # Now that all conflicts are resolved, set transparent borders of
-    # the correct widths on each box. The actual border grid will be
-    # painted separately.
-    def set_transparent_border(box, side, twice_width):
-        box.style[f'border_{side}_style'] = 'solid'
-        box.style[f'border_{side}_width'] = twice_width / 2
-        box.style[f'border_{side}_color'] = TRANSPARENT
-
-    def remove_borders(box):
-        set_transparent_border(box, 'top', 0)
-        set_transparent_border(box, 'right', 0)
-        set_transparent_border(box, 'bottom', 0)
-        set_transparent_border(box, 'left', 0)
-
-    def max_vertical_width(x, y, h):
-        return max(
-            width for grid_row in vertical_borders[y:y + h]
-            for _, (_, width, _) in [grid_row[x]])
-
-    def max_horizontal_width(x, y, w):
-        return max(
-            width for _, (_, width, _) in horizontal_borders[y][x:x + w])
-
-    grid_y = 0
-    for row_group in table.children:
-        remove_borders(row_group)
-        for row in row_group.children:
-            remove_borders(row)
-            for cell in row.children:
-                set_transparent_border(cell, 'top', max_horizontal_width(
-                    x=cell.grid_x, y=grid_y, w=cell.colspan))
-                set_transparent_border(cell, 'bottom', max_horizontal_width(
-                    x=cell.grid_x, y=grid_y + cell.rowspan, w=cell.colspan))
-                set_transparent_border(cell, 'left', max_vertical_width(
-                    x=cell.grid_x, y=grid_y, h=cell.rowspan))
-                set_transparent_border(cell, 'right', max_vertical_width(
-                    x=cell.grid_x + cell.colspan, y=grid_y, h=cell.rowspan))
-            grid_y += 1
-
-    for column_group in table.column_groups:
-        remove_borders(column_group)
-        for column in column_group.children:
-            remove_borders(column)
-
-    set_transparent_border(table, 'top', max_horizontal_width(
-        x=0, y=0, w=grid_width))
-    set_transparent_border(table, 'bottom', max_horizontal_width(
-        x=0, y=grid_height, w=grid_width))
-    # "UAs must compute an initial left and right border width for the table
-    #  by examining the first and last cells in the first row of the table."
-    # https://www.w3.org/TR/CSS21/tables.html#collapsing-borders
-    # ... so h=1, not grid_height:
-    set_transparent_border(table, 'left', max_vertical_width(
-        x=0, y=0, h=1))
-    set_transparent_border(table, 'right', max_vertical_width(
-        x=grid_width, y=0, h=1))
-
-    return vertical_borders, horizontal_borders
-
-
 def flex_boxes(box):
     """Remove and add boxes according to the flex model.
 
diff --git a/weasyprint/layout/percent.py b/weasyprint/layout/percent.py
index 8577aa1c..c2f405f0 100644
--- a/weasyprint/layout/percent.py
+++ b/weasyprint/layout/percent.py
@@ -92,10 +92,14 @@ def resolve_percentages(box, containing_block, main_flex_direction=None):
         resolve_one_percentage(
             box, 'max_height', cb_height, main_flex_direction)
 
+    collapse = box.style['border_collapse'] == 'collapse'
     # Used value == computed value
     for side in ('top', 'right', 'bottom', 'left'):
         prop = f'border_{side}_width'
-        setattr(box, prop, box.style[prop])
+        # border-{side}-width would have been resolved
+        # during border conflict resolution for collapsed-borders
+        if not (collapse and hasattr(box, prop)):
+            setattr(box, prop, box.style[prop])
 
     # Shrink *content* widths and heights according to box-sizing
     # Thanks heavens and the spec: Our validator rejects negative values
diff --git a/weasyprint/layout/preferred.py b/weasyprint/layout/preferred.py
index be8150c9..bd783b29 100644
--- a/weasyprint/layout/preferred.py
+++ b/weasyprint/layout/preferred.py
@@ -135,6 +135,10 @@ def margin_width(box, width, left=True, right=True):
     """Add box paddings, borders and margins to ``width``."""
     percentages = 0
 
+    # See https://drafts.csswg.org/css-tables-3/#cell-intrinsic-offsets
+    # It is a set of computed values for border-left-width, padding-left,
+    # padding-right, and border-right-width (along with zero values for
+    # margin-left and margin-right)
     for value in (
         (['margin_left', 'padding_left'] if left else []) +
         (['margin_right', 'padding_right'] if right else [])
@@ -147,10 +151,24 @@ def margin_width(box, width, left=True, right=True):
                 assert style_value.unit == '%'
                 percentages += style_value.value
 
+    collapse = box.style['border_collapse'] == 'collapse'
     if left:
-        width += box.style['border_left_width']
+        if collapse and hasattr(box, 'border_left_width'):
+            # In collapsed-borders mode: the computed horizontal padding of the
+            # cell and, for border values, the used border-width values of the
+            # cell (half the winning border-width)
+            width += box.border_left_width
+        else:
+            # In separated-borders mode: the computed horizontal padding and
+            # border of the table-cell
+            width += box.style['border_left_width']
     if right:
-        width += box.style['border_right_width']
+        if collapse and hasattr(box, 'border_right_width'):
+            # [...] the used border-width values of the cell
+            width += box.border_right_width
+        else:
+            # [...] the computed border of the table-cell
+            width += box.style['border_right_width']
 
     if percentages < 100:
         return width / (1 - percentages / 100)
diff --git a/weasyprint/layout/table.py b/weasyprint/layout/table.py
index 72764606..35b3e4cb 100644
--- a/weasyprint/layout/table.py
+++ b/weasyprint/layout/table.py
@@ -2,6 +2,8 @@
 
 from math import inf
 
+import tinycss2.color3
+
 from ..formatting_structure import boxes
 from ..logger import LOGGER
 from .percent import resolve_one_percentage, resolve_percentages
@@ -979,3 +981,158 @@ def distribute_excess_width(context, grid, excess_width, column_widths,
 
     # Fifth group, part 2, aka abort
     return excess_width
+
+
+TRANSPARENT = tinycss2.color3.parse_color('transparent')
+
+
+def collapse_table_borders(table, grid_width, grid_height):
+    """Resolve border conflicts for a table in the collapsing border model.
+
+    Take a :class:`TableBox`; set appropriate border widths on the table,
+    column group, column, row group, row, and cell boxes; and return
+    a data structure for the resolved collapsed border grid.
+
+    """
+    if not (grid_width and grid_height):
+        # Don’t bother with empty tables
+        return [], []
+
+    styles = reversed([
+        'hidden', 'double', 'solid', 'dashed', 'dotted', 'ridge', 'outset',
+        'groove', 'inset', 'none'])
+    style_scores = {style: score for score, style in enumerate(styles)}
+    style_map = {'inset': 'ridge', 'outset': 'groove'}
+    weak_null_border = (
+        (0, 0, style_scores['none']), ('none', 0, TRANSPARENT))
+    vertical_borders = [
+        [weak_null_border] * (grid_width + 1) for _ in range(grid_height)]
+    horizontal_borders = [
+        [weak_null_border] * grid_width for _ in range(grid_height + 1)]
+
+    def set_one_border(border_grid, box_style, side, grid_x, grid_y):
+        from ..draw import get_color
+
+        style = box_style[f'border_{side}_style']
+        width = box_style[f'border_{side}_width']
+        color = get_color(box_style, f'border_{side}_color')
+
+        # https://www.w3.org/TR/CSS21/tables.html#border-conflict-resolution
+        score = ((1 if style == 'hidden' else 0), width, style_scores[style])
+
+        style = style_map.get(style, style)
+        previous_score, _ = border_grid[grid_y][grid_x]
+        # Strict < so that the earlier call wins in case of a tie.
+        if previous_score < score:
+            border_grid[grid_y][grid_x] = (score, (style, width, color))
+
+    def set_borders(box, x, y, w, h):
+        style = box.style
+        for yy in range(y, y + h):
+            set_one_border(vertical_borders, style, 'left', x, yy)
+            set_one_border(vertical_borders, style, 'right', x + w, yy)
+        for xx in range(x, x + w):
+            set_one_border(horizontal_borders, style, 'top', xx, y)
+            set_one_border(horizontal_borders, style, 'bottom', xx, y + h)
+
+    # The order is important here:
+    # "A style set on a cell wins over one on a row, which wins over a
+    #  row group, column, column group and, lastly, table"
+    # See https://www.w3.org/TR/CSS21/tables.html#border-conflict-resolution
+    strong_null_border = (
+        (1, 0, style_scores['hidden']), ('hidden', 0, TRANSPARENT))
+    grid_y = 0
+    for row_group in table.children:
+        for row in row_group.children:
+            for cell in row.children:
+                # No border inside of a cell with rowspan or colspan
+                for xx in range(cell.grid_x + 1, cell.grid_x + cell.colspan):
+                    for yy in range(grid_y, grid_y + cell.rowspan):
+                        vertical_borders[yy][xx] = strong_null_border
+                for xx in range(cell.grid_x, cell.grid_x + cell.colspan):
+                    for yy in range(grid_y + 1, grid_y + cell.rowspan):
+                        horizontal_borders[yy][xx] = strong_null_border
+                # The cell’s own borders
+                set_borders(cell, x=cell.grid_x, y=grid_y,
+                            w=cell.colspan, h=cell.rowspan)
+            grid_y += 1
+
+    grid_y = 0
+    for row_group in table.children:
+        for row in row_group.children:
+            set_borders(row, x=0, y=grid_y, w=grid_width, h=1)
+            grid_y += 1
+
+    grid_y = 0
+    for row_group in table.children:
+        rowspan = len(row_group.children)
+        set_borders(row_group, x=0, y=grid_y, w=grid_width, h=rowspan)
+        grid_y += rowspan
+
+    for column_group in table.column_groups:
+        for column in column_group.children:
+            set_borders(column, x=column.grid_x, y=0, w=1, h=grid_height)
+
+    for column_group in table.column_groups:
+        set_borders(column_group, x=column_group.grid_x, y=0,
+                    w=column_group.span, h=grid_height)
+
+    set_borders(table, x=0, y=0, w=grid_width, h=grid_height)
+
+    # Now that all conflicts are resolved, set transparent borders of
+    # the correct widths on each box. The actual border grid will be
+    # painted separately.
+    def set_border_used_width(box, side, twice_width):
+        prop = f'border_{side}_width'
+        setattr(box, prop, twice_width / 2)
+
+    def remove_borders(box):
+        set_border_used_width(box, 'top', 0)
+        set_border_used_width(box, 'right', 0)
+        set_border_used_width(box, 'bottom', 0)
+        set_border_used_width(box, 'left', 0)
+
+    def max_vertical_width(x, y, h):
+        return max(
+            width for grid_row in vertical_borders[y:y + h]
+            for _, (_, width, _) in [grid_row[x]])
+
+    def max_horizontal_width(x, y, w):
+        return max(
+            width for _, (_, width, _) in horizontal_borders[y][x:x + w])
+
+    grid_y = 0
+    for row_group in table.children:
+        remove_borders(row_group)
+        for row in row_group.children:
+            remove_borders(row)
+            for cell in row.children:
+                set_border_used_width(cell, 'top', max_horizontal_width(
+                    x=cell.grid_x, y=grid_y, w=cell.colspan))
+                set_border_used_width(cell, 'bottom', max_horizontal_width(
+                    x=cell.grid_x, y=grid_y + cell.rowspan, w=cell.colspan))
+                set_border_used_width(cell, 'left', max_vertical_width(
+                    x=cell.grid_x, y=grid_y, h=cell.rowspan))
+                set_border_used_width(cell, 'right', max_vertical_width(
+                    x=cell.grid_x + cell.colspan, y=grid_y, h=cell.rowspan))
+            grid_y += 1
+
+    for column_group in table.column_groups:
+        remove_borders(column_group)
+        for column in column_group.children:
+            remove_borders(column)
+
+    set_border_used_width(table, 'top', max_horizontal_width(
+        x=0, y=0, w=grid_width))
+    set_border_used_width(table, 'bottom', max_horizontal_width(
+        x=0, y=grid_height, w=grid_width))
+    # "UAs must compute an initial left and right border width for the table
+    #  by examining the first and last cells in the first row of the table."
+    # https://www.w3.org/TR/CSS21/tables.html#collapsing-borders
+    # ... so h=1, not grid_height:
+    set_border_used_width(table, 'left', max_vertical_width(
+        x=0, y=0, h=1))
+    set_border_used_width(table, 'right', max_vertical_width(
+        x=grid_width, y=0, h=1))
+
+    return vertical_borders, horizontal_borders


Test patch:
diff --git a/tests/draw/test_table.py b/tests/draw/test_table.py
index 95fb0117..6015ee20 100644
--- a/tests/draw/test_table.py
+++ b/tests/draw/test_table.py
@@ -1298,3 +1298,223 @@ def test_tables_23(assert_pixels):
       <table>
         <thead><tr><td>abcde</td><td>abcde</td></tr></thead>
         <tbody><tr><td>abc abc</td><td></td></tr></tbody>''')
+
+
+@assert_no_logs
+def test_running_elements_table_border_collapse(assert_pixels):
+    assert_pixels(2 * '''
+      KK_____________
+      KK_____________
+      _______________
+      _______________
+      _______________
+      KKKKKKK________
+      KRRKRRK________
+      KRRKRRK________
+      KKKKKKK________
+      KRRKRRK________
+      KRRKRRK________
+      KKKKKKK________
+      _______________
+      _______________
+      _______________
+    ''', '''
+      <style>
+        @font-face { src: url(weasyprint.otf); font-family: weasyprint }
+        @page {
+          margin: 0 0 10px 0;
+          size: 15px;
+          @bottom-left { content: element(table) }
+        }
+        body { font: 2px/1 weasyprint }
+        table {
+          border: 1px solid black;
+          border-collapse: collapse;
+          color: red;
+          position: running(table);
+        }
+        td { border: 1px solid black }
+        div { page-break-after: always }
+      </style>
+      <table>
+        <tr> <td>A</td> <td>B</td> </tr>
+        <tr> <td>C</td> <td>D</td> </tr>
+      </table>
+      <div>1</div>
+      <div>2</div>
+    ''')
+
+
+@assert_no_logs
+def test_running_elements_table_border_collapse_empty(assert_pixels):
+    assert_pixels(2 * '''
+      KK________
+      KK________
+      __________
+      __________
+      __________
+      __________
+      __________
+      __________
+      __________
+      __________
+    ''', '''
+      <style>
+        @font-face { src: url(weasyprint.otf); font-family: weasyprint }
+        @page {
+          margin: 0 0 5px 0;
+          size: 10px;
+          @bottom-left { content: element(table) }
+        }
+        body { font: 2px/1 weasyprint }
+        table {
+          border: 1px solid black;
+          border-collapse: collapse;
+          color: red;
+          position: running(table);
+        }
+        td { border: 1px solid black }
+        div { page-break-after: always }
+      </style>
+      <table></table>
+      <div>1</div>
+      <div>2</div>
+    ''')
+
+
+@pytest.mark.xfail
+@assert_no_logs
+def test_running_elements_table_border_collapse_border_style(assert_pixels):
+    assert_pixels(2 * '''
+      KK_____________
+      KK_____________
+      _______________
+      _______________
+      _______________
+      KKKZ___________
+      KRR_RR_________
+      KRR_RR_________
+      KKKK__Z________
+      KRRKRRK________
+      KRRKRRK________
+      KKKKKKK________
+      _______________
+      _______________
+      _______________
+    ''', '''
+      <style>
+        @font-face { src: url(weasyprint.otf); font-family: weasyprint }
+        @page {
+          margin: 0 0 10px 0;
+          size: 15px;
+          @bottom-left { content: element(table) }
+        }
+        body { font: 2px/1 weasyprint }
+        table {
+          border: 1px solid black;
+          border-collapse: collapse;
+          color: red;
+          position: running(table);
+        }
+        td { border: 1px solid black }
+        div { page-break-after: always }
+      </style>
+      <table>
+        <tr> <td>A</td> <td style="border-style: hidden">B</td> </tr>
+        <tr> <td>C</td> <td style="border-style: none">D</td> </tr>
+      </table>
+      <div>1</div>
+      <div>2</div>
+    ''')
+
+
+@assert_no_logs
+def test_running_elements_table_border_collapse_span(assert_pixels):
+    assert_pixels(2 * '''
+      KK_____________
+      KK_____________
+      _______________
+      _______________
+      _______________
+      KKKKKKKKKK_____
+      KRRKRRKRRK_____
+      KRRKRRKRRK_____
+      K__KKKKKKK_____
+      K__KRR___K_____
+      K__KRR___K_____
+      KKKKKKKKKK_____
+      _______________
+      _______________
+      _______________
+    ''', '''
+      <style>
+        @font-face { src: url(weasyprint.otf); font-family: weasyprint }
+        @page {
+          margin: 0 0 10px 0;
+          size: 15px;
+          @bottom-left { content: element(table) }
+        }
+        body { font: 2px/1 weasyprint }
+        table {
+          border: 1px solid black;
+          border-collapse: collapse;
+          color: red;
+          position: running(table);
+        }
+        td { border: 1px solid black }
+        div { page-break-after: always }
+      </style>
+      <table>
+        <tr> <td rowspan=2>A</td> <td>B</td> <td>C</td> </tr>
+        <tr> <td colspan=2>D</td> </tr>
+      </table>
+      <div>1</div>
+      <div>2</div>
+    ''')
+
+
+@pytest.mark.xfail
+@assert_no_logs
+def test_running_elements_table_border_collapse_margin(assert_pixels):
+    assert_pixels(2 * '''
+      KK_____________
+      KK_____________
+      _______________
+      _______________
+      _______________
+      _______________
+      ____KKKKKKK____
+      ____KRRKRRK____
+      ____KRRKRRK____
+      ____KKKKKKK____
+      ____KRRKRRK____
+      ____KRRKRRK____
+      ____KKKKKKK____
+      _______________
+      _______________
+    ''', '''
+      <style>
+        @font-face { src: url(weasyprint.otf); font-family: weasyprint }
+        @page {
+          margin: 0 0 10px 0;
+          size: 15px;
+          @bottom-center { content: element(table); width: 100% }
+        }
+        body { font: 2px/1 weasyprint }
+        table {
+          border: 1px solid black;
+          border-collapse: collapse;
+          color: red;
+          margin: 1px auto;
+          position: running(table);
+        }
+        td { border: 1px solid black }
+        div { page-break-after: always }
+      </style>
+      <table>
+        <tr> <td>A</td> <td>B</td> </tr>
+        <tr> <td>C</td> <td>D</td> </tr>
+      </table>
+      <div>1</div>
+      <div>2</div>
+    ''')
diff --git a/tests/layout/test_table.py b/tests/layout/test_table.py
index 5c36a08a..00fbaac1 100644
--- a/tests/layout/test_table.py
+++ b/tests/layout/test_table.py
@@ -1,8 +1,24 @@
 """Tests for layout of tables."""
 
 import pytest
+from weasyprint.formatting_structure import boxes
+from weasyprint.layout.table import collapse_table_borders
 
-from ..testing_utils import assert_no_logs, capture_logs, render_pages
+from ..testing_utils import (
+    assert_no_logs, capture_logs, parse_all, render_pages)
+
+
+def _get_grid(html, grid_width, grid_height):
+    html = parse_all(html)
+    body, = html.children
+    table_wrapper, = body.children
+    table, = table_wrapper.children
+    border_lists = collapse_table_borders(table, grid_width, grid_height)
+    return tuple(
+        [[(style, width, color) if width else None
+          for _score, (style, width, color) in border]
+         for border in border_list]
+        for border_list in border_lists)
 
 
 @assert_no_logs
@@ -2991,3 +3007,119 @@ table.key-val tr td:nth-child(1) { min-width: 13em; }
 
     assert table1_td1.min_width == table2_td1.min_width
     assert table1_td1.width == table2_td1.width
+
+
+black = (0, 0, 0, 1)
+red = (1, 0, 0, 1)
+green = (0, 1, 0, 1)  # lime in CSS
+blue = (0, 0, 1, 1)
+yellow = (1, 1, 0, 1)
+black_3 = ('solid', 3, black)
+red_1 = ('solid', 1, red)
+yellow_5 = ('solid', 5, yellow)
+green_5 = ('solid', 5, green)
+dashed_blue_5 = ('dashed', 5, blue)
+
+
+@assert_no_logs
+def test_border_collapse_1():
+    html = parse_all('<table></table>')
+    body, = html.children
+    table_wrapper, = body.children
+    table, = table_wrapper.children
+    assert isinstance(table, boxes.TableBox)
+    assert not hasattr(table, 'collapsed_border_grid')
+
+    grid = _get_grid('<table style="border-collapse: collapse"></table>', 0, 0)
+    assert grid == ([], [])
+
+
+@assert_no_logs
+def test_border_collapse_2():
+    vertical_borders, horizontal_borders = _get_grid('''
+      <style>td { border: 1px solid red }</style>
+      <table style="border-collapse: collapse; border: 3px solid black">
+        <tr> <td>A</td> <td>B</td> </tr>
+        <tr> <td>C</td> <td>D</td> </tr>
+      </table>
+    ''', 2, 2)
+    assert vertical_borders == [
+        [black_3, red_1, black_3],
+        [black_3, red_1, black_3],
+    ]
+    assert horizontal_borders == [
+        [black_3, black_3],
+        [red_1, red_1],
+        [black_3, black_3],
+    ]
+
+
+@assert_no_logs
+def test_border_collapse_3():
+    # hidden vs. none
+    vertical_borders, horizontal_borders = _get_grid('''
+      <style>table, td { border: 3px solid }</style>
+      <table style="border-collapse: collapse">
+        <tr> <td>A</td> <td style="border-style: hidden">B</td> </tr>
+        <tr> <td>C</td> <td style="border-style: none">D</td> </tr>
+      </table>
+    ''', 2, 2)
+    assert vertical_borders == [
+        [black_3, None, None],
+        [black_3, black_3, black_3],
+    ]
+    assert horizontal_borders == [
+        [black_3, None],
+        [black_3, None],
+        [black_3, black_3],
+    ]
+
+
+@assert_no_logs
+def test_border_collapse_4():
+    vertical_borders, horizontal_borders = _get_grid('''
+      <style>td { border: 1px solid red }</style>
+      <table style="border-collapse: collapse; border: 5px solid yellow">
+        <col style="border: 3px solid black" />
+        <tr> <td></td> <td></td> <td></td> </tr>
+        <tr> <td></td> <td style="border: 5px dashed blue"></td>
+          <td style="border: 5px solid lime"></td> </tr>
+        <tr> <td></td> <td></td> <td></td> </tr>
+        <tr> <td></td> <td></td> <td></td> </tr>
+      </table>
+    ''', 3, 4)
+    assert vertical_borders == [
+        [yellow_5, black_3, red_1, yellow_5],
+        [yellow_5, dashed_blue_5, green_5, green_5],
+        [yellow_5, black_3, red_1, yellow_5],
+        [yellow_5, black_3, red_1, yellow_5],
+    ]
+    assert horizontal_borders == [
+        [yellow_5, yellow_5, yellow_5],
+        [red_1, dashed_blue_5, green_5],
+        [red_1, dashed_blue_5, green_5],
+        [red_1, red_1, red_1],
+        [yellow_5, yellow_5, yellow_5],
+    ]
+
+
+@assert_no_logs
+def test_border_collapse_5():
+    # rowspan and colspan
+    vertical_borders, horizontal_borders = _get_grid('''
+        <style>col, tr { border: 3px solid }</style>
+        <table style="border-collapse: collapse">
+            <col /><col /><col />
+            <tr> <td rowspan=2></td> <td></td> <td></td> </tr>
+            <tr>                     <td colspan=2></td> </tr>
+        </table>
+    ''', 3, 2)
+    assert vertical_borders == [
+        [black_3, black_3, black_3, black_3],
+        [black_3, black_3, None, black_3],
+    ]
+    assert horizontal_borders == [
+        [black_3, black_3, black_3],
+        [None, black_3, black_3],
+        [black_3, black_3, black_3],
+    ]
diff --git a/tests/test_boxes.py b/tests/test_boxes.py
index fd809110..e0eb30f4 100644
--- a/tests/test_boxes.py
+++ b/tests/test_boxes.py
@@ -10,18 +10,6 @@ from .testing_utils import (
     render_pages)
 
 
-def _get_grid(html):
-    html = parse_all(html)
-    body, = html.children
-    table_wrapper, = body.children
-    table, = table_wrapper.children
-    return tuple(
-        [[(style, width, color) if width else None
-          for _score, (style, width, color) in column]
-         for column in grid]
-        for grid in table.collapsed_border_grid)
-
-
 @assert_no_logs
 def test_box_tree():
     assert_tree(parse('<p>'), [('p', 'Block', [])])
@@ -1115,122 +1103,6 @@ def test_page_counters():
         assert text_box.text == f'Page {page_number} of 3.'
 
 
-black = (0, 0, 0, 1)
-red = (1, 0, 0, 1)
-green = (0, 1, 0, 1)  # lime in CSS
-blue = (0, 0, 1, 1)
-yellow = (1, 1, 0, 1)
-black_3 = ('solid', 3, black)
-red_1 = ('solid', 1, red)
-yellow_5 = ('solid', 5, yellow)
-green_5 = ('solid', 5, green)
-dashed_blue_5 = ('dashed', 5, blue)
-
-
-@assert_no_logs
-def test_border_collapse_1():
-    html = parse_all('<table></table>')
-    body, = html.children
-    table_wrapper, = body.children
-    table, = table_wrapper.children
-    assert isinstance(table, boxes.TableBox)
-    assert not hasattr(table, 'collapsed_border_grid')
-
-    grid = _get_grid('<table style="border-collapse: collapse"></table>')
-    assert grid == ([], [])
-
-
-@assert_no_logs
-def test_border_collapse_2():
-    vertical_borders, horizontal_borders = _get_grid('''
-      <style>td { border: 1px solid red }</style>
-      <table style="border-collapse: collapse; border: 3px solid black">
-        <tr> <td>A</td> <td>B</td> </tr>
-        <tr> <td>C</td> <td>D</td> </tr>
-      </table>
-    ''')
-    assert vertical_borders == [
-        [black_3, red_1, black_3],
-        [black_3, red_1, black_3],
-    ]
-    assert horizontal_borders == [
-        [black_3, black_3],
-        [red_1, red_1],
-        [black_3, black_3],
-    ]
-
-
-@assert_no_logs
-def test_border_collapse_3():
-    # hidden vs. none
-    vertical_borders, horizontal_borders = _get_grid('''
-      <style>table, td { border: 3px solid }</style>
-      <table style="border-collapse: collapse">
-        <tr> <td>A</td> <td style="border-style: hidden">B</td> </tr>
-        <tr> <td>C</td> <td style="border-style: none">D</td> </tr>
-      </table>
-    ''')
-    assert vertical_borders == [
-        [black_3, None, None],
-        [black_3, black_3, black_3],
-    ]
-    assert horizontal_borders == [
-        [black_3, None],
-        [black_3, None],
-        [black_3, black_3],
-    ]
-
-
-@assert_no_logs
-def test_border_collapse_4():
-    vertical_borders, horizontal_borders = _get_grid('''
-      <style>td { border: 1px solid red }</style>
-      <table style="border-collapse: collapse; border: 5px solid yellow">
-        <col style="border: 3px solid black" />
-        <tr> <td></td> <td></td> <td></td> </tr>
-        <tr> <td></td> <td style="border: 5px dashed blue"></td>
-          <td style="border: 5px solid lime"></td> </tr>
-        <tr> <td></td> <td></td> <td></td> </tr>
-        <tr> <td></td> <td></td> <td></td> </tr>
-      </table>
-    ''')
-    assert vertical_borders == [
-        [yellow_5, black_3, red_1, yellow_5],
-        [yellow_5, dashed_blue_5, green_5, green_5],
-        [yellow_5, black_3, red_1, yellow_5],
-        [yellow_5, black_3, red_1, yellow_5],
-    ]
-    assert horizontal_borders == [
-        [yellow_5, yellow_5, yellow_5],
-        [red_1, dashed_blue_5, green_5],
-        [red_1, dashed_blue_5, green_5],
-        [red_1, red_1, red_1],
-        [yellow_5, yellow_5, yellow_5],
-    ]
-
-
-@assert_no_logs
-def test_border_collapse_5():
-    # rowspan and colspan
-    vertical_borders, horizontal_borders = _get_grid('''
-        <style>col, tr { border: 3px solid }</style>
-        <table style="border-collapse: collapse">
-            <col /><col /><col />
-            <tr> <td rowspan=2></td> <td></td> <td></td> </tr>
-            <tr>                     <td colspan=2></td> </tr>
-        </table>
-    ''')
-    assert vertical_borders == [
-        [black_3, black_3, black_3, black_3],
-        [black_3, black_3, None, black_3],
-    ]
-    assert horizontal_borders == [
-        [black_3, black_3, black_3],
-        [None, black_3, black_3],
-        [black_3, black_3, black_3],
-    ]
-
-
 @assert_no_logs
 @pytest.mark.parametrize('html', (
     '<html style="display: none">',