# 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 ..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 preferred_minimum_width, preferred_width from .min_max import handle_min_max_width, handle_min_max_height class OrientedBox(object): @property def sugar(self): return self.padding_plus_border + self.margin_a + self.margin_b @property def outer(self): return self.sugar + self.inner @property def outer_minimum(self): return self.sugar + ( self.minimum if self.inner == 'auto' else self.inner) @property def outer_preferred(self): return self.sugar + ( self.preferred if self.inner == 'auto' else self.inner) def shrink_to_fit(self, available): self.inner = min(max(self.minimum, available), self.preferred) class VerticalBox(OrientedBox): def __init__(self, context, box): self.context = context 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 1e6 class HorizontalBox(OrientedBox): def __init__(self, context, box): self.context = context 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 = preferred_minimum_width( self.context, self.box, outer=False) return self._minimum @property def preferred(self): if self._preferred is None: self._preferred = preferred_width( self.context, self.box, outer=False) return self._preferred def compute_fixed_dimension(context, 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)(context, 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. # http://lists.w3.org/Archives/Public/www-style/2012Jul/0006.html 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(context, 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(context, box) for box in side_boxes] box_a, box_b, box_c = side_boxes for box in side_boxes: if box.margin_a == 'auto': box.margin_a = 0 if box.margin_b == 'auto': box.margin_b = 0 if box_b.box.is_generated: if box_b.inner == 'auto': ac_preferred = 2 * max( box_a.outer_preferred, box_c.outer_preferred) if outer_sum >= box_b.outer_preferred + ac_preferred: box_b.inner = box_b.preferred else: ac_minimum = 2 * max(box_a.outer_minimum, box_c.outer_minimum) box_b.inner = box_b.minimum available = outer_sum - box_b.outer - ac_minimum if available > 0: weight_ac = ac_preferred - ac_minimum weight_b = box_b.preferred - box_b.minimum weight_sum = weight_ac + weight_b # By definition of preferred and minimum, weights can # not be negative. weight_sum == 0 implies that # preferred == minimum for each box, in which case the # sum can not be both <= and > outer_sum # Therefore, one of the last two 'if' statements would # not have lead us here. assert weight_sum > 0 box_b.inner += available * weight_b / weight_sum if box_a.inner == 'auto': box_a.shrink_to_fit((outer_sum - box_b.outer) / 2 - box_a.sugar) if box_c.inner == 'auto': box_c.shrink_to_fit((outer_sum - box_b.outer) / 2 - box_c.sugar) else: # Non-generated boxes get zero for every box-model property assert box_b.inner == 0 if box_a.inner == box_c.inner == 'auto': if box_a.outer_preferred + box_c.outer_preferred <= outer_sum: box_a.inner = box_a.preferred box_c.inner = box_c.preferred else: box_a.inner = box_a.minimum box_c.inner = box_c.minimum available = outer_sum - box_a.outer - box_c.outer if available > 0: weight_a = box_a.preferred - box_a.minimum weight_c = box_c.preferred - box_c.minimum weight_sum = weight_a + weight_c # By definition of preferred and minimum, weights can # not be negative. weight_sum == 0 implies that # preferred == minimum for each box, in which case the # sum can not be both <= and > outer_sum # Therefore, one of the last two 'if' statements would # not have lead us here. assert weight_sum > 0 box_a.inner += available * weight_a / weight_sum box_c.inner += available * weight_c / weight_sum elif box_a.inner == 'auto': box_a.shrink_to_fit(outer_sum - box_c.outer - box_a.sugar) elif box_c.inner == 'auto': box_c.shrink_to_fit(outer_sum - box_a.outer - box_c.sugar) # And, we’re done! assert 'auto' not in [box.inner for box in side_boxes] # Set the actual attributes back. for box in side_boxes: box.restore_box_attributes() def make_margin_boxes(context, page, counter_values): """Yield laid-out margin boxes for this page.""" # 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 = context.style_for(page.page_type, at_keyword) if style is None: style = page.style.inherit_from() box = boxes.MarginBox(at_keyword, style) # Empty boxes should not be generated, but they may be needed for # the layout of their neighbors. box.is_generated = style.content not in ('normal', 'none') # TODO: get actual counter values at the time of the last page break if box.is_generated: quote_depth = [0] children = build.content_to_boxes( box.style, box, quote_depth, counter_values, context.get_image_from_uri) box = box.copy_with_children(children) # 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) build.process_whitespace(box) resolve_percentages(box, containing_block) if not box.is_generated: box.width = box.height = 0 for side in ('top', 'right', 'bottom', 'left'): box._reset_spacing(side) 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 generated_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.is_generated for box in side_boxes): continue # We need the three boxes together for the variable dimension: compute_variable_dimension( context, side_boxes, vertical, variable_outer) for box, offset in zip(side_boxes, [0, 0.5, 1]): if not box.is_generated: continue box.position_x = position_x box.position_y = position_y if vertical: box.position_y += offset * ( variable_outer - box.margin_height()) else: box.position_x += offset * ( variable_outer - box.margin_width()) compute_fixed_dimension( context, box, fixed_outer, not vertical, prefix in ['top', 'left']) 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.is_generated: continue box.position_x = position_x box.position_y = position_y compute_fixed_dimension( context, box, cb_height, True, 'top' in at_keyword) compute_fixed_dimension( context, box, cb_width, False, 'left' in at_keyword) generated_boxes.append(box) for box in generated_boxes: yield margin_box_content_layout(context, page, box) def margin_box_content_layout(context, page, box): """Layout a margin box’s content once the box has dimensions.""" box, resume_at, next_page, _, _ = block_container_layout( context, 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 page_width_or_height(box, containing_block_size): """Take a :class:`OrientedBox` object and set either width, margin-left and margin-right; or height, margin-top and margin-bottom. "The width and horizontal margins of the page box are then calculated exactly as for a non-replaced block element in normal flow. The height and vertical margins of the page box are calculated analogously (instead of using the block height formulas). In both cases if the values are over-constrained, instead of ignoring any margins, the containing block is resized to coincide with the margin edges of the page box." http://dev.w3.org/csswg/css3-page/#page-box-page-rule http://www.w3.org/TR/CSS21/visudet.html#blockwidth """ remaining = containing_block_size - box.padding_plus_border if box.inner == 'auto': if box.margin_a == 'auto': box.margin_a = 0 if box.margin_b == 'auto': box.margin_b = 0 box.inner = remaining - box.margin_a - box.margin_b elif box.margin_a == box.margin_b == 'auto': box.margin_a = box.margin_b = (remaining - box.inner) / 2 elif box.margin_a == 'auto': box.margin_a = remaining - box.inner - box.margin_b elif box.margin_b == 'auto': box.margin_b = remaining - box.inner - box.margin_a box.restore_box_attributes() @handle_min_max_width def page_width(box, context, containing_block_width): page_width_or_height(HorizontalBox(context, box), containing_block_width) @handle_min_max_height def page_height(box, context, containing_block_height): page_width_or_height(VerticalBox(context, box), containing_block_height) def make_page(context, 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 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 = context.style_for(page_type) # Propagated from the root or . style.overflow = root_box.viewport_overflow page = boxes.PageBox(page_type, style) device_size = page.style.size resolve_percentages(page, device_size) page.position_x = 0 page.position_y = 0 cb_width, cb_height = device_size page_width(page, context, cb_width) page_height(page, context, cb_height) 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) context.create_block_formatting_context() page_is_empty = True adjoining_margins = [] positioned_boxes = [] # Mixed absolute and fixed root_box, resume_at, next_page, _, _ = block_level_layout( context, 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(context, absolute_box, page, positioned_boxes) context.finish_block_formatting_context(root_box) 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(context, 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( context, 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