411 lines
17 KiB
Python
411 lines
17 KiB
Python
|
|
"""Layout for columns."""
|
|||
|
|
|
|||
|
|
from math import floor, inf
|
|||
|
|
|
|||
|
|
from .absolute import absolute_layout
|
|||
|
|
from .percent import resolve_percentages
|
|||
|
|
|
|||
|
|
|
|||
|
|
def columns_layout(context, box, bottom_space, skip_stack, containing_block,
|
|||
|
|
page_is_empty, absolute_boxes, fixed_boxes,
|
|||
|
|
adjoining_margins):
|
|||
|
|
"""Lay out a multi-column ``box``."""
|
|||
|
|
from .block import (
|
|||
|
|
block_box_layout, block_level_layout, block_level_width,
|
|||
|
|
collapse_margin, remove_placeholders)
|
|||
|
|
|
|||
|
|
style = box.style
|
|||
|
|
width = style['column_width']
|
|||
|
|
count = style['column_count']
|
|||
|
|
gap = style['column_gap']
|
|||
|
|
height = style['height']
|
|||
|
|
original_bottom_space = bottom_space
|
|||
|
|
context.in_column = True
|
|||
|
|
|
|||
|
|
if style['position'] == 'relative':
|
|||
|
|
# New containing block, use a new absolute list
|
|||
|
|
absolute_boxes = []
|
|||
|
|
|
|||
|
|
box = box.copy_with_children(box.children)
|
|||
|
|
box.position_y += collapse_margin(adjoining_margins) - box.margin_top
|
|||
|
|
|
|||
|
|
# Set height if defined
|
|||
|
|
if height != 'auto' and height.unit != '%':
|
|||
|
|
assert height.unit == 'px'
|
|||
|
|
height_defined = True
|
|||
|
|
empty_space = context.page_bottom - box.content_box_y() - height.value
|
|||
|
|
bottom_space = max(bottom_space, empty_space)
|
|||
|
|
else:
|
|||
|
|
height_defined = False
|
|||
|
|
|
|||
|
|
# TODO: the columns container width can be unknown if the containing block
|
|||
|
|
# needs the size of this block to know its own size
|
|||
|
|
block_level_width(box, containing_block)
|
|||
|
|
|
|||
|
|
# Define the number of columns and their widths
|
|||
|
|
if width == 'auto' and count != 'auto':
|
|||
|
|
width = max(0, box.width - (count - 1) * gap) / count
|
|||
|
|
elif width != 'auto' and count == 'auto':
|
|||
|
|
count = max(1, int(floor((box.width + gap) / (width + gap))))
|
|||
|
|
width = (box.width + gap) / count - gap
|
|||
|
|
else: # overconstrained, with width != 'auto' and count != 'auto'
|
|||
|
|
count = min(count, int(floor((box.width + gap) / (width + gap))))
|
|||
|
|
width = (box.width + gap) / count - gap
|
|||
|
|
|
|||
|
|
# Handle column-span property with the following structure:
|
|||
|
|
# columns_and_blocks = [
|
|||
|
|
# [column_child_1, column_child_2],
|
|||
|
|
# spanning_block,
|
|||
|
|
# …
|
|||
|
|
# ]
|
|||
|
|
columns_and_blocks = []
|
|||
|
|
column_children = []
|
|||
|
|
skip, = skip_stack.keys() if skip_stack else (0,)
|
|||
|
|
for i, child in enumerate(box.children[skip:], start=skip):
|
|||
|
|
if child.style['column_span'] == 'all':
|
|||
|
|
if column_children:
|
|||
|
|
columns_and_blocks.append(
|
|||
|
|
(i - len(column_children), column_children))
|
|||
|
|
columns_and_blocks.append((i, child.copy()))
|
|||
|
|
column_children = []
|
|||
|
|
continue
|
|||
|
|
column_children.append(child.copy())
|
|||
|
|
if column_children:
|
|||
|
|
columns_and_blocks.append(
|
|||
|
|
(i + 1 - len(column_children), column_children))
|
|||
|
|
|
|||
|
|
if skip_stack:
|
|||
|
|
skip_stack = {0: skip_stack[skip]}
|
|||
|
|
|
|||
|
|
if not box.children:
|
|||
|
|
next_page = {'break': 'any', 'page': None}
|
|||
|
|
skip_stack = None
|
|||
|
|
|
|||
|
|
# Find height and balance.
|
|||
|
|
#
|
|||
|
|
# The current algorithm starts from the total available height, to check
|
|||
|
|
# whether the whole content can fit. If it doesn’t fit, we keep the partial
|
|||
|
|
# rendering. If it fits, we try to balance the columns starting from the
|
|||
|
|
# ideal height (the total height divided by the number of columns). We then
|
|||
|
|
# iterate until the last column is not the highest one. At the end of each
|
|||
|
|
# loop, we add the minimal height needed to make one direct child at the
|
|||
|
|
# top of one column go to the end of the previous column.
|
|||
|
|
#
|
|||
|
|
# We rely on a real rendering for each loop, and with a stupid algorithm
|
|||
|
|
# like this it can last minutes…
|
|||
|
|
|
|||
|
|
adjoining_margins = []
|
|||
|
|
current_position_y = box.content_box_y()
|
|||
|
|
new_children = []
|
|||
|
|
column_skip_stack = None
|
|||
|
|
last_loop = False
|
|||
|
|
break_page = False
|
|||
|
|
footnote_area_heights = [
|
|||
|
|
0 if context.current_footnote_area.height == 'auto'
|
|||
|
|
else context.current_footnote_area.margin_height()]
|
|||
|
|
last_footnotes_height = 0
|
|||
|
|
for index, column_children_or_block in columns_and_blocks:
|
|||
|
|
if not isinstance(column_children_or_block, list):
|
|||
|
|
# We have 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, resume_at, next_page, adjoining_margins, _, _ = (
|
|||
|
|
block_level_layout(
|
|||
|
|
context, block, original_bottom_space, skip_stack,
|
|||
|
|
containing_block, page_is_empty, absolute_boxes,
|
|||
|
|
fixed_boxes, adjoining_margins))
|
|||
|
|
skip_stack = None
|
|||
|
|
if new_child is None:
|
|||
|
|
last_loop = True
|
|||
|
|
break_page = True
|
|||
|
|
break
|
|||
|
|
new_children.append(new_child)
|
|||
|
|
current_position_y = (
|
|||
|
|
new_child.border_height() + new_child.border_box_y())
|
|||
|
|
adjoining_margins.append(new_child.margin_bottom)
|
|||
|
|
if resume_at:
|
|||
|
|
last_loop = True
|
|||
|
|
break_page = True
|
|||
|
|
column_skip_stack = resume_at
|
|||
|
|
break
|
|||
|
|
page_is_empty = False
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
# We have a list of children that we have to balance between columns
|
|||
|
|
column_children = column_children_or_block
|
|||
|
|
|
|||
|
|
# Find the total height available for the first run
|
|||
|
|
current_position_y += collapse_margin(adjoining_margins)
|
|||
|
|
adjoining_margins = []
|
|||
|
|
column_box = _create_column_box(
|
|||
|
|
box, containing_block, column_children, width, current_position_y)
|
|||
|
|
height = max_height = (
|
|||
|
|
context.page_bottom - current_position_y - original_bottom_space)
|
|||
|
|
|
|||
|
|
# Try to render columns until the content fits, increase the column
|
|||
|
|
# height step by step
|
|||
|
|
column_skip_stack = skip_stack
|
|||
|
|
lost_space = inf
|
|||
|
|
original_excluded_shapes = context.excluded_shapes[:]
|
|||
|
|
original_page_is_empty = page_is_empty
|
|||
|
|
page_is_empty = stop_rendering = balancing = False
|
|||
|
|
while True:
|
|||
|
|
# Remove extra excluded shapes introduced during the previous loop
|
|||
|
|
while len(context.excluded_shapes) > len(original_excluded_shapes):
|
|||
|
|
context.excluded_shapes.pop()
|
|||
|
|
|
|||
|
|
# Render the columns
|
|||
|
|
column_skip_stack = skip_stack
|
|||
|
|
consumed_heights = []
|
|||
|
|
new_boxes = []
|
|||
|
|
for i in range(count):
|
|||
|
|
# Render one column
|
|||
|
|
new_box, resume_at, next_page, _, _, _ = block_box_layout(
|
|||
|
|
context, column_box,
|
|||
|
|
context.page_bottom - current_position_y - height,
|
|||
|
|
column_skip_stack, containing_block,
|
|||
|
|
page_is_empty or not balancing, [], [], [],
|
|||
|
|
discard=False, max_lines=None)
|
|||
|
|
if new_box is None:
|
|||
|
|
# We didn't render anything, retry
|
|||
|
|
column_skip_stack = {0: None}
|
|||
|
|
break
|
|||
|
|
new_boxes.append(new_box)
|
|||
|
|
column_skip_stack = resume_at
|
|||
|
|
|
|||
|
|
# Calculate consumed height, empty space and next box height
|
|||
|
|
in_flow_children = [
|
|||
|
|
child for child in new_box.children
|
|||
|
|
if child.is_in_normal_flow()]
|
|||
|
|
if in_flow_children:
|
|||
|
|
# Get the empty space at the bottom of the column box
|
|||
|
|
consumed_height = (
|
|||
|
|
in_flow_children[-1].margin_height() +
|
|||
|
|
in_flow_children[-1].position_y - current_position_y)
|
|||
|
|
empty_space = height - consumed_height
|
|||
|
|
|
|||
|
|
# Get the minimum size needed to render the next box
|
|||
|
|
if column_skip_stack:
|
|||
|
|
next_box = block_box_layout(
|
|||
|
|
context, column_box, inf, column_skip_stack,
|
|||
|
|
containing_block, True, [], [], [],
|
|||
|
|
discard=False, max_lines=None)[0]
|
|||
|
|
for child in next_box.children:
|
|||
|
|
if child.is_in_normal_flow():
|
|||
|
|
next_box_height = child.margin_height()
|
|||
|
|
break
|
|||
|
|
remove_placeholders(context, [next_box], [], [])
|
|||
|
|
else:
|
|||
|
|
next_box_height = 0
|
|||
|
|
else:
|
|||
|
|
consumed_height = empty_space = next_box_height = 0
|
|||
|
|
|
|||
|
|
consumed_heights.append(consumed_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.
|
|||
|
|
#
|
|||
|
|
# We also want to avoid very small values that may have been
|
|||
|
|
# introduced by rounding errors. As the workaround below at
|
|||
|
|
# least adds 1 pixel for each loop, we can ignore lost spaces
|
|||
|
|
# lower than 1px.
|
|||
|
|
if next_box_height - empty_space > 1:
|
|||
|
|
lost_space = min(lost_space, next_box_height - empty_space)
|
|||
|
|
|
|||
|
|
# Stop if we already rendered the whole content
|
|||
|
|
if resume_at is None:
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
# Remove placeholders but keep the current footnote area height
|
|||
|
|
last_footnotes_height = (
|
|||
|
|
0 if context.current_footnote_area.height == 'auto'
|
|||
|
|
else context.current_footnote_area.margin_height())
|
|||
|
|
remove_placeholders(context, new_boxes, [], [])
|
|||
|
|
|
|||
|
|
if last_loop:
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
if balancing:
|
|||
|
|
if column_skip_stack is None:
|
|||
|
|
# We rendered the whole content, stop
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
# Increase the column heights and render them again
|
|||
|
|
add_height = 1 if lost_space == inf else lost_space
|
|||
|
|
height += add_height
|
|||
|
|
|
|||
|
|
if height > max_height:
|
|||
|
|
# We reached max height, stop rendering
|
|||
|
|
height = max_height
|
|||
|
|
stop_rendering = True
|
|||
|
|
break
|
|||
|
|
else:
|
|||
|
|
if last_footnotes_height not in footnote_area_heights:
|
|||
|
|
# Footnotes have been rendered, try to re-render with the
|
|||
|
|
# new footnote area height
|
|||
|
|
height -= last_footnotes_height - footnote_area_heights[-1]
|
|||
|
|
footnote_area_heights.append(last_footnotes_height)
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
everything_fits = (
|
|||
|
|
not column_skip_stack and
|
|||
|
|
max(consumed_heights) <= max_height)
|
|||
|
|
if everything_fits:
|
|||
|
|
# Everything fits, start expanding columns at the average
|
|||
|
|
# of the column heights
|
|||
|
|
max_height -= last_footnotes_height
|
|||
|
|
if (style['column_fill'] == 'balance' or
|
|||
|
|
index < columns_and_blocks[-1][0]):
|
|||
|
|
balancing = True
|
|||
|
|
height = sum(consumed_heights) / count
|
|||
|
|
else:
|
|||
|
|
break
|
|||
|
|
else:
|
|||
|
|
# Content overflows even at maximum height, stop now and
|
|||
|
|
# let the columns continue on the next page
|
|||
|
|
height += footnote_area_heights[-1]
|
|||
|
|
if len(footnote_area_heights) > 2:
|
|||
|
|
last_footnotes_height = min(
|
|||
|
|
last_footnotes_height, footnote_area_heights[-1])
|
|||
|
|
height -= last_footnotes_height
|
|||
|
|
stop_rendering = True
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
# TODO: check style['max']-height
|
|||
|
|
bottom_space = max(
|
|||
|
|
bottom_space, context.page_bottom - current_position_y - height)
|
|||
|
|
|
|||
|
|
# Replace the current box children with real columns
|
|||
|
|
i = 0
|
|||
|
|
max_column_height = 0
|
|||
|
|
columns = []
|
|||
|
|
while True:
|
|||
|
|
column_box = _create_column_box(
|
|||
|
|
box, containing_block, column_children, width,
|
|||
|
|
current_position_y)
|
|||
|
|
if style['direction'] == 'rtl':
|
|||
|
|
column_box.position_x += box.width - (i + 1) * width - i * gap
|
|||
|
|
else:
|
|||
|
|
column_box.position_x += i * (width + gap)
|
|||
|
|
new_child, column_skip_stack, column_next_page, _, _, _ = (
|
|||
|
|
block_box_layout(
|
|||
|
|
context, column_box, bottom_space, skip_stack,
|
|||
|
|
containing_block, original_page_is_empty, absolute_boxes,
|
|||
|
|
fixed_boxes, None, discard=False, max_lines=None))
|
|||
|
|
if new_child is None:
|
|||
|
|
break_page = True
|
|||
|
|
break
|
|||
|
|
next_page = column_next_page
|
|||
|
|
skip_stack = column_skip_stack
|
|||
|
|
columns.append(new_child)
|
|||
|
|
max_column_height = max(
|
|||
|
|
max_column_height, new_child.margin_height())
|
|||
|
|
if skip_stack is None:
|
|||
|
|
bottom_space = original_bottom_space
|
|||
|
|
break
|
|||
|
|
i += 1
|
|||
|
|
if i == count and not height_defined:
|
|||
|
|
# [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
|
|||
|
|
|
|||
|
|
# Update the current y position and set the columns’ height
|
|||
|
|
current_position_y += min(max_height, max_column_height)
|
|||
|
|
for column in columns:
|
|||
|
|
column.height = max_column_height
|
|||
|
|
new_children.append(column)
|
|||
|
|
|
|||
|
|
skip_stack = None
|
|||
|
|
page_is_empty = False
|
|||
|
|
|
|||
|
|
if stop_rendering:
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
# Report footnotes above the defined footnotes height
|
|||
|
|
_report_footnotes(context, last_footnotes_height)
|
|||
|
|
|
|||
|
|
if box.children and not new_children:
|
|||
|
|
# The box has children but none can be drawn, let's skip the whole box
|
|||
|
|
context.in_column = False
|
|||
|
|
return None, (0, None), {'break': 'any', 'page': None}, [], False
|
|||
|
|
|
|||
|
|
# Set the height of the containing box
|
|||
|
|
box.children = new_children
|
|||
|
|
current_position_y += collapse_margin(adjoining_margins)
|
|||
|
|
height = current_position_y - box.content_box_y()
|
|||
|
|
if box.height == 'auto':
|
|||
|
|
box.height = height
|
|||
|
|
height_difference = 0
|
|||
|
|
else:
|
|||
|
|
height_difference = box.height - height
|
|||
|
|
|
|||
|
|
# Update the latest columns’ height to respect min-height
|
|||
|
|
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 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, bottom_space,
|
|||
|
|
skip_stack=None)
|
|||
|
|
|
|||
|
|
# Calculate skip stack
|
|||
|
|
if column_skip_stack:
|
|||
|
|
skip, = column_skip_stack.keys()
|
|||
|
|
skip_stack = {index + skip: column_skip_stack[skip]}
|
|||
|
|
elif break_page:
|
|||
|
|
skip_stack = {index: None}
|
|||
|
|
|
|||
|
|
# Update page bottom according to the new footnotes
|
|||
|
|
if context.current_footnote_area.height != 'auto':
|
|||
|
|
context.page_bottom += footnote_area_heights[0]
|
|||
|
|
context.page_bottom -= context.current_footnote_area.margin_height()
|
|||
|
|
|
|||
|
|
context.in_column = False
|
|||
|
|
return box, skip_stack, next_page, [], False
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _report_footnotes(context, footnotes_height):
|
|||
|
|
"""Report footnotes above the defined footnotes height."""
|
|||
|
|
if not context.current_page_footnotes:
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
# Report and count footnotes
|
|||
|
|
reported_footnotes = 0
|
|||
|
|
while context.current_footnote_area.margin_height() > footnotes_height:
|
|||
|
|
context.report_footnote(context.current_page_footnotes[-1])
|
|||
|
|
reported_footnotes += 1
|
|||
|
|
|
|||
|
|
# Revert reported footnotes, as they’ve been reported starting from the
|
|||
|
|
# last one
|
|||
|
|
if reported_footnotes >= 2:
|
|||
|
|
extra = context.reported_footnotes[-1:-reported_footnotes-1:-1]
|
|||
|
|
context.reported_footnotes[-reported_footnotes:] = extra
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _create_column_box(box, containing_block, children, width, position_y):
|
|||
|
|
"""Create a column box including given children."""
|
|||
|
|
column_box = box.anonymous_from(box, children=children)
|
|||
|
|
resolve_percentages(column_box, containing_block)
|
|||
|
|
column_box.is_column = True
|
|||
|
|
column_box.width = width
|
|||
|
|
column_box.position_x = box.content_box_x()
|
|||
|
|
column_box.position_y = position_y
|
|||
|
|
return column_box
|