mirror of
https://github.com/Kozea/WeasyPrint.git
synced 2024-10-05 00:21:15 +03:00
694863ed62
* Some fixed boxes did not have any layout * Validator for the 'float' property masked the builtin, float('inf') returned None.
540 lines
20 KiB
Python
540 lines
20 KiB
Python
# coding: utf8
|
||
"""
|
||
weasyprint.layout.pages
|
||
-----------------------
|
||
|
||
Layout for pages and CSS3 margin boxes.
|
||
|
||
:copyright: Copyright 2011-2012 Simon Sapin and contributors, see AUTHORS.
|
||
:license: BSD, see LICENSE for details.
|
||
|
||
"""
|
||
|
||
from __future__ import division, unicode_literals
|
||
|
||
from ..logger import LOGGER
|
||
from ..formatting_structure import boxes, build
|
||
from .absolute import absolute_layout
|
||
from .blocks import block_level_layout, block_container_layout
|
||
from .percentages import resolve_percentages
|
||
from .preferred import inline_preferred_minimum_width, inline_preferred_width
|
||
from .variable_margin_dimension import with_rule_2
|
||
|
||
|
||
class VerticalBox(object):
|
||
def __init__(self, document, box):
|
||
self.document = document
|
||
self.box = box
|
||
# Inner dimension: that of the content area, as opposed to the
|
||
# outer dimension: that of the margin area.
|
||
self.inner = box.height
|
||
self.margin_a = box.margin_top
|
||
self.margin_b = box.margin_bottom
|
||
self.padding_plus_border = (
|
||
box.padding_top + box.padding_bottom +
|
||
box.border_top_width + box.border_bottom_width)
|
||
|
||
def restore_box_attributes(self):
|
||
box = self.box
|
||
box.height = self.inner
|
||
box.margin_top = self.margin_a
|
||
box.margin_bottom = self.margin_b
|
||
|
||
# TODO: preferred (minimum) height???
|
||
@property
|
||
def minimum(self):
|
||
return 0
|
||
|
||
@property
|
||
def preferred(self):
|
||
return float('inf')
|
||
|
||
|
||
class HorizontalBox(object):
|
||
def __init__(self, document, box):
|
||
self.document = document
|
||
self.box = box
|
||
self.inner = box.width
|
||
self.margin_a = box.margin_left
|
||
self.margin_b = box.margin_right
|
||
self.padding_plus_border = (
|
||
box.padding_left + box.padding_right +
|
||
box.border_left_width + box.border_right_width)
|
||
self._minimum = None
|
||
self._preferred = None
|
||
|
||
def restore_box_attributes(self):
|
||
box = self.box
|
||
box.width = self.inner
|
||
box.margin_left = self.margin_a
|
||
box.margin_right = self.margin_b
|
||
|
||
@property
|
||
def minimum(self):
|
||
if self._minimum is None:
|
||
self._minimum = inline_preferred_minimum_width(
|
||
self.document, self.box, outer=False)
|
||
return self._minimum
|
||
|
||
@property
|
||
def preferred(self):
|
||
if self._preferred is None:
|
||
self._preferred = inline_preferred_width(
|
||
self.document, self.box, outer=False)
|
||
return self._preferred
|
||
|
||
|
||
def compute_fixed_dimension(document, box, outer, vertical, top_or_left):
|
||
"""
|
||
Compute and set a margin box fixed dimension on ``box``, as described in:
|
||
http://dev.w3.org/csswg/css3-page/#margin-constraints
|
||
|
||
:param box:
|
||
The margin box to work on
|
||
:param outer:
|
||
The target outer dimension (value of a page margin)
|
||
:param vertical:
|
||
True to set height, margin-top and margin-bottom; False for width,
|
||
margin-left and margin-right
|
||
:param top_or_left:
|
||
True if the margin box in if the top half (for vertical==True) or
|
||
left half (for vertical==False) of the page.
|
||
This determines which margin should be 'auto' if the values are
|
||
over-constrained. (Rule 3 of the algorithm.)
|
||
"""
|
||
box = (VerticalBox if vertical else HorizontalBox)(document, box)
|
||
|
||
# Rule 2
|
||
total = box.padding_plus_border + sum(
|
||
value for value in [box.margin_a, box.margin_b, box.inner]
|
||
if value != 'auto')
|
||
if total > outer:
|
||
if box.margin_a == 'auto':
|
||
box.margin_a = 0
|
||
if box.margin_b == 'auto':
|
||
box.margin_b = 0
|
||
if box.inner == 'auto':
|
||
# XXX this is not in the spec, but without it box.inner
|
||
# would end up with a negative value.
|
||
# Instead, this will trigger rule 3 below.
|
||
box.inner = 0
|
||
# Rule 3
|
||
if 'auto' not in [box.margin_a, box.margin_b, box.inner]:
|
||
# Over-constrained
|
||
if top_or_left:
|
||
box.margin_a = 'auto'
|
||
else:
|
||
box.margin_b = 'auto'
|
||
# Rule 4
|
||
if [box.margin_a, box.margin_b, box.inner].count('auto') == 1:
|
||
if box.inner == 'auto':
|
||
box.inner = (outer - box.padding_plus_border -
|
||
box.margin_a - box.margin_b)
|
||
elif box.margin_a == 'auto':
|
||
box.margin_a = (outer - box.padding_plus_border -
|
||
box.margin_b - box.inner)
|
||
elif box.margin_b == 'auto':
|
||
box.margin_b = (outer - box.padding_plus_border -
|
||
box.margin_a - box.inner)
|
||
# Rule 5
|
||
if box.inner == 'auto':
|
||
if box.margin_a == 'auto':
|
||
box.margin_a = 0
|
||
if box.margin_b == 'auto':
|
||
box.margin_b = 0
|
||
box.inner = (outer - box.padding_plus_border -
|
||
box.margin_a - box.margin_b)
|
||
# Rule 6
|
||
if box.margin_a == 'auto' and box.margin_b == 'auto':
|
||
box.margin_a = box.margin_b = (
|
||
outer - box.padding_plus_border - box.inner) / 2
|
||
|
||
assert 'auto' not in [box.margin_a, box.margin_b, box.inner]
|
||
# This should also be true, but may not be exact due to
|
||
# floating point errors:
|
||
# assert (box.inner + box.padding_plus_border +
|
||
# box.margin_a + box.margin_b) == outer
|
||
box.restore_box_attributes()
|
||
|
||
|
||
def compute_variable_dimension(document, side_boxes, vertical, outer_sum):
|
||
"""
|
||
Compute and set a margin box fixed dimension on ``box``, as described in:
|
||
http://dev.w3.org/csswg/css3-page/#margin-dimension
|
||
|
||
:param side_boxes: Three boxes on a same side (as opposed to a corner.)
|
||
A list of:
|
||
- A @*-left or @*-top margin box
|
||
- A @*-center or @*-middle margin box
|
||
- A @*-right or @*-bottom margin box
|
||
:param vertical:
|
||
True to set height, margin-top and margin-bottom; False for width,
|
||
margin-left and margin-right
|
||
:param outer_sum:
|
||
The target total outer dimension (max box width or height)
|
||
|
||
"""
|
||
box_class = VerticalBox if vertical else HorizontalBox
|
||
side_boxes = [box_class(document, box) for box in side_boxes]
|
||
box_a, box_b, box_c = side_boxes
|
||
|
||
num_auto_margins = sum(
|
||
value == 'auto' # boolean as int
|
||
for box in side_boxes
|
||
for value in (box.margin_a, box.margin_b)
|
||
)
|
||
|
||
if box_b.box.exists:
|
||
# TODO: remove this when Margin boxes variable dimension is correct
|
||
if not document._auto_margin_boxes_warning_shown and (
|
||
any('auto' in [box.margin_a, box.margin_b]
|
||
for box in side_boxes)):
|
||
LOGGER.warn("Margin boxes with 'auto' margins are not supported. "
|
||
"You may get unexpected results.")
|
||
document._auto_margin_boxes_warning_shown = True
|
||
|
||
# Rule 2: outer(box_a) == outer(box_b)
|
||
with_rule_2(side_boxes, outer_sum)
|
||
else:
|
||
# Rule 2 does not apply
|
||
# Rule 1: Target sum of all 'auto' values
|
||
remaining = outer_sum - sum(
|
||
value
|
||
for box in side_boxes
|
||
for value in [box.margin_a, box.margin_b, box.inner,
|
||
box.padding_plus_border]
|
||
if value != 'auto')
|
||
|
||
# Not empty because box_b does not "exist", box_b.inner == 'auto'
|
||
auto_inner_boxes = [box for box in side_boxes if box.inner == 'auto']
|
||
min_inners = [box.minimum for box in auto_inner_boxes]
|
||
max_inners = [box.preferred for box in auto_inner_boxes]
|
||
sum_min_inners = sum(min_inners)
|
||
sum_max_inners = sum(max_inners)
|
||
# Minimize margins while keeping inner dimensions within bounds
|
||
if remaining < sum_min_inners:
|
||
# minimum widths are bigger than the target sum for
|
||
# 'auto' values.
|
||
# Use that, and 'auto' margins will be negative
|
||
# Content will most likely overlap.
|
||
for box, min_inner in zip(auto_inner_boxes, min_inners):
|
||
# Rule 5
|
||
box.inner = min_inner
|
||
sum_margins = remaining - sum_min_inners
|
||
# If remaining is not within range and the number of auto margins
|
||
# is zero the problem is over-constrained.
|
||
# The maximum constraints are the first to be dropped in this
|
||
# case: only keep them if there are auto margins.
|
||
elif remaining > sum_max_inners and num_auto_margins > 0:
|
||
for box, max_inner in zip(auto_inner_boxes, max_inners):
|
||
# Rule 6
|
||
box.inner = max_inner
|
||
sum_margins = remaining - sum_max_inners
|
||
else:
|
||
sum_margins = 0
|
||
sum_inners = remaining
|
||
|
||
weights = [
|
||
(max_inner / sum_max_inners
|
||
if max_inner != float('inf') and sum_max_inners != 0
|
||
else 1 / len(auto_inner_boxes))
|
||
for box, max_inner in zip(auto_inner_boxes, max_inners)
|
||
]
|
||
# sum(weights) == 1, with some floating point error
|
||
|
||
if remaining > sum_max_inners:
|
||
# num_auto_margins == 0
|
||
max_inners = [float('inf')] * 3
|
||
sum_max_inners = float('inf')
|
||
|
||
# Choose the inner dimension for all boxes with 'auto'
|
||
# but the last
|
||
for box, max_inner, min_inner, weight in list(zip(
|
||
auto_inner_boxes, max_inners, min_inners, weights
|
||
))[:-1]:
|
||
# Ideal inner for A, to balance contents
|
||
target = sum_inners * weight
|
||
# The ranges for other boxes combined with the sum
|
||
# constraint restrict the range for this box:
|
||
if sum_max_inners != float('inf'):
|
||
others_sum_max = sum_max_inners - max_inner
|
||
min_inner = max(min_inner, sum_inners - others_sum_max)
|
||
others_sum_min = sum_min_inners - min_inner
|
||
max_inner = min(max_inner, sum_inners - others_sum_min)
|
||
# As close as possible to target, but within bounds
|
||
box.inner = min(max_inner, max(min_inner, target))
|
||
# The dimension for the last box is resolved with the
|
||
# target sum
|
||
auto_inner_boxes[-1].inner = sum_inners - sum(
|
||
box.inner for box in auto_inner_boxes[:-1])
|
||
|
||
if sum_margins == 0:
|
||
# Valid even if there is no 'auto' margin
|
||
each_auto_margin = 0
|
||
else:
|
||
if num_auto_margins == 0:
|
||
# Over-constrained: ignore the computed values of these margins
|
||
box_a.margin_b = 'auto'
|
||
box_c.margin_a = 'auto'
|
||
num_auto_margins = 2
|
||
each_auto_margin = sum_margins / num_auto_margins
|
||
for box in side_boxes:
|
||
if box.margin_a == 'auto':
|
||
box.margin_a = each_auto_margin
|
||
if box.margin_b == 'auto':
|
||
box.margin_b = each_auto_margin
|
||
|
||
|
||
# And, we’re done!
|
||
assert all(
|
||
value != 'auto'
|
||
for box in side_boxes
|
||
for value in [box.margin_a, box.margin_b, box.inner])
|
||
# Set the actual attributes back.
|
||
for box in side_boxes:
|
||
box.restore_box_attributes()
|
||
|
||
|
||
def make_margin_boxes(document, page, counter_values):
|
||
"""Yield laid-out margin boxes for this page.
|
||
|
||
:param document: a :class:`Document` object
|
||
:param page: a :class:`PageBox` object
|
||
|
||
"""
|
||
# This is a closure only to make calls shorter
|
||
def make_box(at_keyword, containing_block):
|
||
"""
|
||
Return a margin box with resolved percentages, but that may still
|
||
have 'auto' values.
|
||
|
||
Return ``None`` if this margin box should not be generated.
|
||
|
||
:param at_keyword: which margin box to return, eg. '@top-left'
|
||
:param containing_block: as expected by :func:`resolve_percentages`.
|
||
|
||
"""
|
||
style = document.style_for(page.page_type, at_keyword)
|
||
if style is None:
|
||
style = page.style.inherit_from()
|
||
box = boxes.MarginBox(at_keyword, style)
|
||
# TODO: get actual counter values at the time of the last page break
|
||
quote_depth = [0]
|
||
if style.content not in ('normal', 'none'):
|
||
children = build.content_to_boxes(
|
||
document, box.style, box, quote_depth, counter_values)
|
||
box = box.copy_with_children(children)
|
||
build.process_whitespace(box)
|
||
resolve_percentages(box, containing_block)
|
||
# Empty boxes should not be generated, but they may be needed for
|
||
# the layout of their neighbors.
|
||
box.exists = (
|
||
style.content not in ('normal', 'none') or style.width != 'auto')
|
||
return box
|
||
|
||
margin_top = page.margin_top
|
||
margin_bottom = page.margin_bottom
|
||
margin_left = page.margin_left
|
||
margin_right = page.margin_right
|
||
max_box_width = page.border_width()
|
||
max_box_height = page.border_height()
|
||
|
||
# bottom right corner of the border box
|
||
page_end_x = margin_left + max_box_width
|
||
page_end_y = margin_top + max_box_height
|
||
|
||
# Margin box dimensions, described in
|
||
# http://dev.w3.org/csswg/css3-page/#margin-box-dimensions
|
||
|
||
# Order recommended on http://dev.w3.org/csswg/css3-page/#painting
|
||
# center/middle on top (last in tree order), then corner, then others
|
||
|
||
# First, boxes that are neither corner nor center/middle
|
||
# Delay center/middle boxes
|
||
generated_boxes = []
|
||
delayed_boxes = []
|
||
|
||
for prefix, vertical, containing_block, position_x, position_y in [
|
||
('top', False, (max_box_width, margin_top),
|
||
margin_left, 0),
|
||
('bottom', False, (max_box_width, margin_bottom),
|
||
margin_left, page_end_y),
|
||
('left', True, (margin_left, max_box_height),
|
||
0, margin_top),
|
||
('right', True, (margin_right, max_box_height),
|
||
page_end_x, margin_top),
|
||
]:
|
||
if vertical:
|
||
suffixes = ['top', 'middle', 'bottom']
|
||
fixed_outer, variable_outer = containing_block
|
||
else:
|
||
suffixes = ['left', 'center', 'right']
|
||
variable_outer, fixed_outer = containing_block
|
||
side_boxes = [make_box('@%s-%s' % (prefix, suffix), containing_block)
|
||
for suffix in suffixes]
|
||
if not any(box.exists for box in side_boxes):
|
||
continue
|
||
# We need the three boxes together for the variable dimension:
|
||
compute_variable_dimension(
|
||
document, side_boxes, vertical, variable_outer)
|
||
for box, delay in zip(side_boxes, [False, True, False]):
|
||
compute_fixed_dimension(
|
||
document, box, fixed_outer, not vertical,
|
||
prefix in ['top', 'left'])
|
||
box.position_x = position_x
|
||
box.position_y = position_y
|
||
if vertical:
|
||
position_y += box.margin_height()
|
||
else:
|
||
position_x += box.margin_width()
|
||
if not box.exists:
|
||
continue
|
||
if delay:
|
||
delayed_boxes.append(box)
|
||
else:
|
||
generated_boxes.append(box)
|
||
|
||
# Corner boxes
|
||
|
||
for at_keyword, cb_width, cb_height, position_x, position_y in [
|
||
('@top-left-corner', margin_left, margin_top, 0, 0),
|
||
('@top-right-corner', margin_right, margin_top, page_end_x, 0),
|
||
('@bottom-left-corner', margin_left, margin_bottom, 0, page_end_y),
|
||
('@bottom-right-corner', margin_right, margin_bottom,
|
||
page_end_x, page_end_y),
|
||
]:
|
||
box = make_box(at_keyword, (cb_width, cb_height))
|
||
if not box.exists:
|
||
continue
|
||
box.position_x = position_x
|
||
box.position_y = position_y
|
||
compute_fixed_dimension(
|
||
document, box, cb_height, True, 'top' in at_keyword)
|
||
compute_fixed_dimension(
|
||
document, box, cb_width, False, 'left' in at_keyword)
|
||
generated_boxes.append(box)
|
||
|
||
generated_boxes.extend(delayed_boxes)
|
||
|
||
for box in generated_boxes:
|
||
yield margin_box_content_layout(document, page, box)
|
||
|
||
|
||
def margin_box_content_layout(document, page, box):
|
||
"""Layout a margin box’s content once the box has dimensions."""
|
||
# content_to_boxes() only produces inline-level boxes, no need to
|
||
# run other post-processors from build.build_formatting_structure()
|
||
box = build.inline_in_block(box)
|
||
box, resume_at, next_page, _, _ = block_container_layout(
|
||
document, box,
|
||
max_position_y=float('inf'), skip_stack=None,
|
||
device_size=page.style.size, page_is_empty=True,
|
||
absolute_boxes=[], fixed_boxes=[])
|
||
assert resume_at is None
|
||
|
||
vertical_align = box.style.vertical_align
|
||
# Every other value is read as 'top', ie. no change.
|
||
if vertical_align in ('middle', 'bottom') and box.children:
|
||
first_child = box.children[0]
|
||
last_child = box.children[-1]
|
||
top = first_child.position_y
|
||
# Not always exact because floating point errors
|
||
#assert top == box.content_box_y()
|
||
bottom = last_child.position_y + last_child.margin_height()
|
||
content_height = bottom - top
|
||
offset = box.height - content_height
|
||
if vertical_align == 'middle':
|
||
offset /= 2
|
||
for child in box.children:
|
||
child.translate(0, offset)
|
||
return box
|
||
|
||
|
||
def make_page(document, root_box, page_type, resume_at, content_empty):
|
||
"""Take just enough content from the beginning to fill one page.
|
||
|
||
Return ``(page, finished)``. ``page`` is a laid out PageBox object
|
||
and ``resume_at`` indicates where in the document to start the next page,
|
||
or is ``None`` if this was the last page.
|
||
|
||
:param document: a Document object
|
||
:param page_number: integer, start at 1 for the first page
|
||
:param resume_at: as returned by ``make_page()`` for the previous page,
|
||
or ``None`` for the first page.
|
||
|
||
"""
|
||
style = document.style_for(page_type)
|
||
# Propagated from the root or <body>.
|
||
style.overflow = root_box.viewport_overflow
|
||
page = boxes.PageBox(page_type, style)
|
||
|
||
device_size = page.style.size
|
||
page.outer_width, page.outer_height = device_size
|
||
|
||
resolve_percentages(page, device_size)
|
||
|
||
page.position_x = 0
|
||
page.position_y = 0
|
||
page.width = page.outer_width - page.horizontal_surroundings()
|
||
page.height = page.outer_height - page.vertical_surroundings()
|
||
|
||
document.excluded_shapes = []
|
||
|
||
root_box.position_x = page.content_box_x()
|
||
root_box.position_y = page.content_box_y()
|
||
page_content_bottom = root_box.position_y + page.height
|
||
initial_containing_block = page
|
||
|
||
if content_empty:
|
||
previous_resume_at = resume_at
|
||
root_box = root_box.copy_with_children([])
|
||
|
||
# TODO: handle cases where the root element is something else.
|
||
# See http://www.w3.org/TR/CSS21/visuren.html#dis-pos-flo
|
||
assert isinstance(root_box, boxes.BlockBox)
|
||
page_is_empty = True
|
||
adjoining_margins = []
|
||
positioned_boxes = [] # Mixed absolute and fixed
|
||
root_box, resume_at, next_page, _, _ = block_level_layout(
|
||
document, root_box, page_content_bottom, resume_at,
|
||
initial_containing_block, device_size, page_is_empty,
|
||
positioned_boxes, positioned_boxes, adjoining_margins)
|
||
assert root_box
|
||
|
||
for absolute_box in positioned_boxes:
|
||
absolute_layout(document, absolute_box, page, positioned_boxes)
|
||
|
||
page = page.copy_with_children([root_box])
|
||
if content_empty:
|
||
resume_at = previous_resume_at
|
||
return page, resume_at, next_page
|
||
|
||
|
||
def make_all_pages(document, root_box):
|
||
"""Return a list of laid out pages without margin boxes."""
|
||
prefix = 'first_'
|
||
|
||
# Special case the root box
|
||
page_break = root_box.style.page_break_before
|
||
if page_break == 'right':
|
||
right_page = True
|
||
if page_break == 'left':
|
||
right_page = False
|
||
else:
|
||
right_page = root_box.style.direction == 'ltr'
|
||
|
||
resume_at = None
|
||
next_page = 'any'
|
||
while True:
|
||
page_type = prefix + ('right_page' if right_page else 'left_page')
|
||
content_empty = ((next_page == 'left' and right_page) or
|
||
(next_page == 'right' and not right_page))
|
||
page, resume_at, next_page = make_page(
|
||
document, root_box, page_type, resume_at, content_empty)
|
||
assert next_page
|
||
yield page
|
||
if resume_at is None:
|
||
return
|
||
prefix = ''
|
||
right_page = not right_page
|