diff --git a/docs/features.rst b/docs/features.rst index c915393c5..c043f93c0 100644 --- a/docs/features.rst +++ b/docs/features.rst @@ -559,7 +559,7 @@ are supported. The ``break-before``, ``break-after`` and ``break-inside`` properties are **not** supported. -The ``column-span`` property is **not** supported. +The ``column-span`` property is supported for direct children of columns. The ``column-fill`` property is supported, with a column balancing algorithm that should be efficient with simple cases. diff --git a/weasyprint/css/validation/properties.py b/weasyprint/css/validation/properties.py index 95df1a038..5282eda33 100644 --- a/weasyprint/css/validation/properties.py +++ b/weasyprint/css/validation/properties.py @@ -411,8 +411,7 @@ def column_width(token): @single_keyword def column_span(keyword): """``column-span`` property validation.""" - # TODO: uncomment this when it is supported - # return keyword in ('all', 'none') + return keyword in ('all', 'none') @property() diff --git a/weasyprint/layout/columns.py b/weasyprint/layout/columns.py index 20db8e0e1..bc28ae867 100644 --- a/weasyprint/layout/columns.py +++ b/weasyprint/layout/columns.py @@ -11,7 +11,6 @@ from math import floor -from ..formatting_structure import boxes from .absolute import absolute_layout from .percentages import resolve_percentages @@ -21,13 +20,15 @@ def columns_layout(context, box, max_position_y, skip_stack, containing_block, adjoining_margins): """Lay out a multi-column ``box``.""" # Avoid circular imports - from .blocks import block_box_layout, block_level_width + from .blocks import ( + block_box_layout, block_level_layout, block_level_width, + collapse_margin) # Implementation of the multi-column pseudo-algorithm: # https://www.w3.org/TR/css3-multicol/#pseudo-algorithm - count = None width = None style = box.style + original_max_position_y = max_position_y if box.style['position'] == 'relative': # New containing block, use a new absolute list @@ -48,30 +49,28 @@ def columns_layout(context, box, max_position_y, skip_stack, containing_block, # the size of this block to know its own size. block_level_width(box, containing_block) available_width = box.width - if count is None: - if style['column_width'] == 'auto' and style['column_count'] != 'auto': - count = style['column_count'] - width = max( - 0, available_width - (count - 1) * style['column_gap']) / count - elif (style['column_width'] != 'auto' and - style['column_count'] == 'auto'): - count = max(1, int(floor( - (available_width + style['column_gap']) / - (style['column_width'] + style['column_gap'])))) - width = ( - (available_width + style['column_gap']) / count - - style['column_gap']) - else: - count = min(style['column_count'], int(floor( - (available_width + style['column_gap']) / - (style['column_width'] + style['column_gap'])))) - width = ( - (available_width + style['column_gap']) / count - - style['column_gap']) - - def create_column_box(): - column_box = box.anonymous_from(box, children=[ - child.copy() for child in box.children]) + if style['column_width'] == 'auto' and style['column_count'] != 'auto': + count = style['column_count'] + width = max( + 0, available_width - (count - 1) * style['column_gap']) / count + elif (style['column_width'] != 'auto' and + style['column_count'] == 'auto'): + count = max(1, int(floor( + (available_width + style['column_gap']) / + (style['column_width'] + style['column_gap'])))) + width = ( + (available_width + style['column_gap']) / count - + style['column_gap']) + else: + count = min(style['column_count'], int(floor( + (available_width + style['column_gap']) / + (style['column_width'] + style['column_gap'])))) + width = ( + (available_width + style['column_gap']) / count - + style['column_gap']) + + def create_column_box(children): + column_box = box.anonymous_from(box, children=children) resolve_percentages(column_box, containing_block) column_box.is_column = True column_box.width = width @@ -79,16 +78,29 @@ def create_column_box(): column_box.position_y = box.content_box_y() return column_box - def column_descendants(box): - # TODO: this filtering condition is probably wrong - if isinstance(box, (boxes.TableBox, boxes.LineBox, boxes.ReplacedBox)): - yield box - if hasattr(box, 'descendants') and box.is_in_normal_flow(): - for child in box.children: - if child.is_in_normal_flow(): - yield child - for grand_child in column_descendants(child): - yield grand_child + # Handle column-span property. + # We want to get the following structure: + # columns_and_blocks = [ + # [column_child_1, column_child_2], + # spanning_block, + # … + # ] + columns_and_blocks = [] + column_children = [] + for child in box.children: + if child.style['column_span'] == 'all': + if column_children: + columns_and_blocks.append(column_children) + columns_and_blocks.append(child.copy()) + column_children = [] + continue + column_children.append(child.copy()) + if column_children: + columns_and_blocks.append(column_children) + + if not box.children: + next_page = {'break': 'any', 'page': None} + skip_stack = None # Balance. # @@ -98,78 +110,108 @@ def column_descendants(box): # height needed to make one direct child at the top of one column go to the # end of the previous column. # - # We must probably rely on a real rendering for each loop, but with a - # stupid algorithm like this it can last minutes. - # - # TODO: Rewrite this! - # - We assume that the children are normal lines or blocks. - # - We ignore the forced and avoided column breaks. + # We rely on a real rendering for each loop, and with a stupid algorithm + # like this it can last minutes… - # Find the total height of the content - original_max_position_y = max_position_y - column_box = create_column_box() - new_child, _, _, _, _ = block_box_layout( - context, column_box, float('inf'), skip_stack, containing_block, - device_size, page_is_empty, [], [], []) - height = new_child.margin_height() - if style['column_fill'] == 'balance': - height /= count - box_column_descendants = list(column_descendants(new_child)) - - # Increase the column height step by step. - while True: - # For each step, we try to find the empty height needed to make the top - # element of column i+1 fit at the end of column i. We put this needed - # space in lost_spaces. - lost_spaces = [] - column_number = 0 - column_first_child = True - column_top = new_child.content_box_y() - for child in box_column_descendants: - child_height = child.margin_height() - child_bottom = child.position_y + child_height - column_top - if child_bottom > height: - # The child goes lower than the column height. - if column_number < count - 1: - # We're not in the last column. - if column_first_child: - # It's the first child of the column and we're already - # below the bottom of the column. The column's height - # has to be at least the size of the child. Let's put - # the height difference into lost_spaces and continue - # the while loop. - lost_spaces = [child_bottom - height] - break - # Put the child at the top of the next column and put the - # extra empty space that would have allowed this child to - # fit into lost_spaces. - lost_spaces.append(child_bottom - height) - column_number += 1 - column_first_child = True - column_top = child.position_y - else: - # We're in the last column, there's no place left to put - # that child. We need to go for another round of the while - # loop. + adjoining_margins = [] + current_position_y = box.content_box_y() + new_children = [] + for column_children_or_block in columns_and_blocks: + if not isinstance(column_children_or_block, list): + # We get a spanning block, we display it like other blocks. + block = column_children_or_block + resolve_percentages(block, containing_block) + block.position_x = box.content_box_x() + block.position_y = current_position_y + new_child, _, _, adjoining_margins, _ = block_level_layout( + context, block, original_max_position_y, skip_stack, + containing_block, device_size, page_is_empty, absolute_boxes, + fixed_boxes, adjoining_margins) + new_children.append(new_child) + current_position_y = ( + new_child.border_height() + new_child.border_box_y()) + adjoining_margins.append(new_child.margin_bottom) + continue + + # We have a list of children that we have to balance between columns. + column_children = column_children_or_block + + # Find the total height of the content + current_position_y += collapse_margin(adjoining_margins) + adjoining_margins = [] + column_box = create_column_box(column_children) + new_child, _, _, _, _ = block_box_layout( + context, column_box, float('inf'), skip_stack, containing_block, + device_size, page_is_empty, [], [], []) + height = new_child.margin_height() + if style['column_fill'] == 'balance': + height /= count + + # Try to render columns until the content fits, increase the column + # height step by step. + column_skip_stack = skip_stack + lost_space = float('inf') + while True: + for i in range(count): + # Render the column + new_box, resume_at, next_page, _, _ = block_box_layout( + context, column_box, box.content_box_y() + height, + column_skip_stack, containing_block, device_size, + page_is_empty, [], [], []) + column_skip_stack = resume_at + + # Get the empty space at the bottom of the column box + empty_space = height - ( + new_box.children[-1].position_y - box.content_box_y() + + new_box.children[-1].margin_height()) + + # Get the minimum size needed to render the next box + next_box, _, _, _, _ = block_box_layout( + context, column_box, box.content_box_y(), + column_skip_stack, containing_block, device_size, True, + [], [], []) + next_box_size = next_box.children[0].margin_height() + + # Append the size needed to render the next box in this + # column. + # + # The next box size may be smaller than the empty space, for + # example when the next box can't be separated from its own + # next box. In this case we don't try to find the real value + # and let the workaround below fix this for us. + if next_box_size - empty_space > 0: + lost_space = min(lost_space, next_box_size - empty_space) + + # Stop if we already rendered the whole content + if resume_at is None: break - column_first_child = False - else: - # We've seen all the children and they all fit in their - # columns. Balanced height has been found, quit the while loop. - break - height += min(lost_spaces) - # TODO: check box.style['max']-height - max_position_y = min(max_position_y, box.content_box_y() + height) + if column_skip_stack is None: + # We rendered the whole content, stop + break + else: + if lost_space == float('inf'): + # We didn't find the extra size needed to render a child in + # the previous column, increase height by the minimal + # value. + height += 1 + else: + # Increase the columns heights and render them once again + height += lost_space + column_skip_stack = skip_stack - # Replace the current box children with columns - children = [] - if box.children: + # TODO: check box.style['max']-height + max_position_y = min(max_position_y, box.content_box_y() + height) + + # Replace the current box children with columns i = 0 + max_column_height = 0 + columns = [] while True: if i == count - 1: max_position_y = original_max_position_y - column_box = create_column_box() + column_box = create_column_box(column_children) + column_box.position_y = current_position_y if style['direction'] == 'rtl': column_box.position_x += ( box.width - (i + 1) * width - i * style['column_gap']) @@ -184,40 +226,48 @@ def column_descendants(box): break next_page = column_next_page skip_stack = column_skip_stack - children.append(new_child) + columns.append(new_child) + max_column_height = max( + max_column_height, new_child.margin_height()) if skip_stack is None: break i += 1 if i == count and not known_height: - # [If] a declaration that constrains the column height (e.g., - # using height or max-height). In this case, additional column - # boxes are created in the inline direction. + # [If] a declaration that constrains the column height + # (e.g., using height or max-height). In this case, + # additional column boxes are created in the inline + # direction. break - else: - next_page = {'break': 'any', 'page': None} - skip_stack = None - if box.children and not children: + current_position_y += max_column_height + for column in columns: + column.height = max_column_height + new_children.append(column) + + if box.children and not new_children: # The box has children but none can be drawn, let's skip the whole box - return None, (0, None), {'break': 'any', 'page': None}, [0], False + return None, (0, None), {'break': 'any', 'page': None}, [], False # Set the height of box and the columns - box.children = children - if box.children: - heights = [child.margin_height() for child in box.children] - if box.height != 'auto': - heights.append(box.height) - if box.min_height != 'auto': - heights.append(box.min_height) - box.height = max(heights) - for child in box.children: - child.height = box.margin_height() + box.children = new_children + current_position_y += collapse_margin(adjoining_margins) + if box.height == 'auto': + box.height = current_position_y - box.position_y + height_difference = 0 else: - box.height = 0 + height_difference = box.height - (current_position_y - box.position_y) + if box.min_height != 'auto' and box.min_height > box.height: + height_difference += box.min_height - box.height + box.height = box.min_height + for child in new_children[::-1]: + if child.is_column: + child.height += height_difference + else: + break if box.style['position'] == 'relative': # New containing block, resolve the layout of the absolute descendants for absolute_box in absolute_boxes: absolute_layout(context, absolute_box, box, fixed_boxes) - return box, skip_stack, next_page, [0], False + return box, skip_stack, next_page, [], False diff --git a/weasyprint/tests/test_layout/test_column.py b/weasyprint/tests/test_layout/test_column.py index db319a78f..e98118330 100644 --- a/weasyprint/tests/test_layout/test_column.py +++ b/weasyprint/tests/test_layout/test_column.py @@ -83,6 +83,37 @@ def test_column_gap(value, width): assert [column.position_y for column in columns] == [0, 0, 0] +@assert_no_logs +def test_column_span(): + page, = render_pages(''' + + +
+ abc def +
test
+
test
+ ghi jkl +
+ ''') + html, = page.children + body, = html.children + div, = body.children + column1, column2, section1, section2, column3, column4 = div.children + assert (column1.position_x, column1.position_y) == (0, 0) + assert (column2.position_x, column2.position_y) == (5 * 16, 0) + assert (section1.content_box_x(), section1.content_box_y()) == (0, 32) + assert (section2.content_box_x(), section2.content_box_y()) == (0, 64) + assert (column3.position_x, column3.position_y) == (0, 96) + assert (column4.position_x, column4.position_y) == (5 * 16, 96) + + assert column1.height == 16 + + @assert_no_logs def test_columns_multipage(): page1, page2 = render_pages('''