"""Classes for all types of boxes in the CSS formatting structure / box model. See https://www.w3.org/TR/CSS21/visuren.html Names are the same as in CSS 2.1 with the exception of ``TextBox``. In WeasyPrint, any text is in a ``TextBox``. What CSS calls anonymous inline boxes are text boxes but not all text boxes are anonymous inline boxes. See https://www.w3.org/TR/CSS21/visuren.html#anonymous Abstract classes, should not be instantiated: * Box * BlockLevelBox * InlineLevelBox * BlockContainerBox * ReplacedBox * ParentBox * AtomicInlineLevelBox Concrete classes: * PageBox * BlockBox * InlineBox * InlineBlockBox * BlockReplacedBox * InlineReplacedBox * TextBox * LineBox * Various table-related Box subclasses All concrete box classes whose name contains "Inline" or "Block" have one of the following "outside" behavior: * Block-level (inherits from :class:`BlockLevelBox`) * Inline-level (inherits from :class:`InlineLevelBox`) and one of the following "inside" behavior: * Block container (inherits from :class:`BlockContainerBox`) * Inline content (InlineBox and :class:`TextBox`) * Replaced content (inherits from :class:`ReplacedBox`) … with various combinasions of both. See respective docstrings for details. """ import itertools from ..css import computed_from_cascaded class Box: """Abstract base class for all boxes.""" # Definitions for the rules generating anonymous table boxes # https://www.w3.org/TR/CSS21/tables.html#anonymous-boxes proper_table_child = False internal_table_or_caption = False tabular_container = False # Keep track of removed collapsing spaces for wrap opportunities. leading_collapsible_space = False trailing_collapsible_space = False # Default, may be overriden on instances. is_table_wrapper = False is_flex_item = False is_grid_item = False is_for_root_element = False is_column = False is_leader = False # Other properties transformation_matrix = None bookmark_label = None string_set = None footnote = None cached_counter_values = None missing_link = None # Default, overriden on some subclasses def all_children(self): return self.children def descendants(self, placeholders=False): """A flat generator for a box, its children and descendants.""" yield self for child in self.children: if placeholders or isinstance(child, Box): yield from child.descendants(placeholders) else: yield child def __init__(self, element_tag, style, element): self.element_tag = element_tag self.element = element self.style = style self.remove_decoration_sides = set() self.children = [] def __repr__(self): return f'<{type(self).__name__} {self.element_tag}>' @classmethod def anonymous_from(cls, parent, *args, **kwargs): """Return an anonymous box that inherits from ``parent``.""" style = computed_from_cascaded( cascaded={}, parent_style=parent.style, element=None) return cls(parent.element_tag, style, parent.element, *args, **kwargs) def copy(self): """Return shallow copy of the box.""" cls = type(self) # Create a new instance without calling __init__: parameters are # different depending on the class. new_box = cls.__new__(cls) # Copy attributes new_box.__dict__.update(self.__dict__) return new_box def deepcopy(self): """Return a copy of the box with recursive copies of its children.""" return self.copy() def translate(self, dx=0, dy=0, ignore_floats=False): """Change the box’s position. Also update the children’s positions accordingly. """ # Overridden in ParentBox to also translate children, if any. if dx == dy == 0: return self.position_x += dx self.position_y += dy for child in self.all_children(): if not (ignore_floats and child.is_floated()): child.translate(dx, dy, ignore_floats) # Heights and widths def padding_width(self): """Width of the padding box.""" return self.width + self.padding_left + self.padding_right def padding_height(self): """Height of the padding box.""" return self.height + self.padding_top + self.padding_bottom def border_width(self): """Width of the border box.""" return self.padding_width() + self.border_left_width + \ self.border_right_width def border_height(self): """Height of the border box.""" return self.padding_height() + self.border_top_width + \ self.border_bottom_width def margin_width(self): """Width of the margin box (aka. outer box).""" return self.border_width() + self.margin_left + self.margin_right def margin_height(self): """Height of the margin box (aka. outer box).""" return self.border_height() + self.margin_top + self.margin_bottom # Corners positions def content_box_x(self): """Absolute horizontal position of the content box.""" return self.position_x + self.margin_left + self.padding_left + \ self.border_left_width def content_box_y(self): """Absolute vertical position of the content box.""" return self.position_y + self.margin_top + self.padding_top + \ self.border_top_width def padding_box_x(self): """Absolute horizontal position of the padding box.""" return self.position_x + self.margin_left + self.border_left_width def padding_box_y(self): """Absolute vertical position of the padding box.""" return self.position_y + self.margin_top + self.border_top_width def border_box_x(self): """Absolute horizontal position of the border box.""" return self.position_x + self.margin_left def border_box_y(self): """Absolute vertical position of the border box.""" return self.position_y + self.margin_top def hit_area(self): """Return the (x, y, w, h) rectangle where the box is clickable.""" # "Border area. That's the area that hit-testing is done on." # https://lists.w3.org/Archives/Public/www-style/2012Jun/0318.html # TODO: manage the border radii, use outer_border_radii instead return (self.border_box_x(), self.border_box_y(), self.border_width(), self.border_height()) def rounded_box(self, bt, br, bb, bl): """Position, size and radii of a box inside the outer border box. bt, br, bb, and bl are distances from the outer border box, defining a rectangle to be rounded. """ tlrx, tlry = self.border_top_left_radius trrx, trry = self.border_top_right_radius brrx, brry = self.border_bottom_right_radius blrx, blry = self.border_bottom_left_radius tlrx = max(0, tlrx - bl) tlry = max(0, tlry - bt) trrx = max(0, trrx - br) trry = max(0, trry - bt) brrx = max(0, brrx - br) brry = max(0, brry - bb) blrx = max(0, blrx - bl) blry = max(0, blry - bb) x = self.border_box_x() + bl y = self.border_box_y() + bt width = self.border_width() - bl - br height = self.border_height() - bt - bb # Fix overlapping curves # See https://www.w3.org/TR/css-backgrounds-3/#corner-overlap ratio = min([1] + [ extent / sum_radii for extent, sum_radii in ( (width, tlrx + trrx), (width, blrx + brrx), (height, tlry + blry), (height, trry + brry), ) if sum_radii > 0 ]) return ( x, y, width, height, (tlrx * ratio, tlry * ratio), (trrx * ratio, trry * ratio), (brrx * ratio, brry * ratio), (blrx * ratio, blry * ratio)) def rounded_box_ratio(self, ratio): return self.rounded_box( self.border_top_width * ratio, self.border_right_width * ratio, self.border_bottom_width * ratio, self.border_left_width * ratio) def rounded_padding_box(self): """Return the position, size and radii of the rounded padding box.""" return self.rounded_box( self.border_top_width, self.border_right_width, self.border_bottom_width, self.border_left_width) def rounded_border_box(self): """Return the position, size and radii of the rounded border box.""" return self.rounded_box(0, 0, 0, 0) def rounded_content_box(self): """Return the position, size and radii of the rounded content box.""" return self.rounded_box( self.border_top_width + self.padding_top, self.border_right_width + self.padding_right, self.border_bottom_width + self.padding_bottom, self.border_left_width + self.padding_left) # Positioning schemes def is_floated(self): """Return whether this box is floated.""" return self.style['float'] in ('left', 'right') def is_footnote(self): """Return whether this box is a footnote.""" return self.style['float'] == 'footnote' def is_absolutely_positioned(self): """Return whether this box is in the absolute positioning scheme.""" return self.style['position'] in ('absolute', 'fixed') def is_running(self): """Return whether this box is a running element.""" return self.style['position'][0] == 'running()' def is_in_normal_flow(self): """Return whether this box is in normal flow.""" return not ( self.is_floated() or self.is_absolutely_positioned() or self.is_running() or self.is_footnote()) def is_monolithic(self): """Return whether this box is monolithic.""" # https://www.w3.org/TR/css-break-3/#monolithic return ( isinstance(self, AtomicInlineLevelBox) or isinstance(self, ReplacedBox) or self.style['overflow'] in ('auto', 'scroll') or (self.style['overflow'] == 'hidden' and self.style['height'] != 'auto')) # Start and end page values for named pages def page_values(self): """Return start and end page values.""" return (self.style['page'], self.style['page']) # PDF attachments def is_attachment(self): """Return whether this link should be stored as a PDF attachment.""" from ..html import element_has_link_type if self.element is not None and self.element.tag == 'a': return element_has_link_type(self.element, 'attachment') return False # Forms def is_input(self): """Return whether this box is a form input.""" # https://html.spec.whatwg.org/multipage/forms.html#category-submit if self.style['appearance'] == 'auto' and self.element is not None: if self.element.tag in ('button', 'input', 'select', 'textarea'): return not isinstance(self, (LineBox, TextBox)) return False class ParentBox(Box): """A box that has children.""" def __init__(self, element_tag, style, element, children): super().__init__(element_tag, style, element) self.children = tuple(children) def _reset_spacing(self, side): """Set to 0 the margin, padding and border of ``side``.""" self.remove_decoration_sides.add(side) setattr(self, f'margin_{side}', 0) setattr(self, f'padding_{side}', 0) setattr(self, f'border_{side}_width', 0) def remove_decoration(self, start, end): if self.style['box_decoration_break'] == 'clone': return if start: self._reset_spacing('top') if end: self._reset_spacing('bottom') def copy_with_children(self, new_children): """Create a new equivalent box with given ``new_children``.""" new_box = self.copy() new_box.children = new_children # Clear and reset removed decorations as we don't want to keep the # previous data, for example when a box is split between two pages. self.remove_decoration_sides = set() return new_box def deepcopy(self): result = self.copy() result.children = list(child.deepcopy() for child in self.children) return result def get_wrapped_table(self): """Get the table wrapped by the box.""" assert self.is_table_wrapper for child in self.children: if isinstance(child, TableBox): return child else: # pragma: no cover raise ValueError('Table wrapper without a table') def page_values(self): start_value, end_value = super().page_values() # TODO: We should find Class A possible page breaks according to # https://drafts.csswg.org/css-page-3/#propdef-page # Keep only children in normal flow for now. children = [ child for child in self.children if child.is_in_normal_flow()] if children: if len(children) == 1: page_values = children[0].page_values() start_value = page_values[0] or start_value end_value = page_values[1] or end_value else: start_box, end_box = children[0], children[-1] start_value = start_box.page_values()[0] or start_value end_value = end_box.page_values()[1] or end_value return start_value, end_value class BlockLevelBox(Box): """A box that participates in an block formatting context. An element with a ``display`` value of ``block``, ``list-item`` or ``table`` generates a block-level box. """ clearance = None class BlockContainerBox(ParentBox): """A box that contains only block-level boxes or only line boxes. A box that either contains only block-level boxes or establishes an inline formatting context and thus contains only line boxes. A non-replaced element with a ``display`` value of ``block``, ``list-item``, ``inline-block`` or 'table-cell' generates a block container box. """ class BlockBox(BlockContainerBox, BlockLevelBox): """A block-level box that is also a block container. A non-replaced element with a ``display`` value of ``block``, ``list-item`` generates a block box. """ class LineBox(ParentBox): """A box that represents a line in an inline formatting context. Can only contain inline-level boxes. In early stages of building the box tree a single line box contains many consecutive inline boxes. Later, during layout phase, each line boxes will be split into multiple line boxes, one for each actual line. """ text_overflow = 'clip' block_ellipsis = 'none' @classmethod def anonymous_from(cls, parent, *args, **kwargs): box = super().anonymous_from(parent, *args, **kwargs) if parent.style['overflow'] != 'visible': box.text_overflow = parent.style['text_overflow'] return box class InlineLevelBox(Box): """A box that participates in an inline formatting context. An inline-level box that is not an inline box is said to be "atomic". Such boxes are inline blocks, replaced elements and inline tables. An element with a ``display`` value of ``inline``, ``inline-table``, or ``inline-block`` generates an inline-level box. """ def remove_decoration(self, start, end): if self.style['box_decoration_break'] == 'clone': return ltr = self.style['direction'] == 'ltr' if start: self._reset_spacing('left' if ltr else 'right') if end: self._reset_spacing('right' if ltr else 'left') class InlineBox(InlineLevelBox, ParentBox): """An inline box with inline children. A box that participates in an inline formatting context and whose content also participates in that inline formatting context. A non-replaced element with a ``display`` value of ``inline`` generates an inline box. """ link_annotation = None def hit_area(self): """Return the (x, y, w, h) rectangle where the box is clickable.""" # Use line-height (margin_height) rather than border_height return (self.border_box_x(), self.position_y, self.border_width(), self.margin_height()) class TextBox(InlineLevelBox): """A box that contains only text and has no box children. Any text in the document ends up in a text box. What CSS calls "anonymous inline boxes" are also text boxes. """ justification_spacing = 0 def __init__(self, element_tag, style, element, text): assert text super().__init__(element_tag, style, element) self.text = text def copy_with_text(self, text): """Return a new TextBox identical to this one except for the text.""" assert text new_box = self.copy() new_box.text = text return new_box class AtomicInlineLevelBox(InlineLevelBox): """An atomic box in an inline formatting context. This inline-level box cannot be split for line breaks. """ class InlineBlockBox(AtomicInlineLevelBox, BlockContainerBox): """A box that is both inline-level and a block container. It behaves as inline on the outside and as a block on the inside. A non-replaced element with a 'display' value of 'inline-block' generates an inline-block box. """ class ReplacedBox(Box): """A box whose content is replaced. For example, ```` are replaced: their content is rendered externally and is opaque from CSS’s point of view. """ def __init__(self, element_tag, style, element, replacement): super().__init__(element_tag, style, element) self.replacement = replacement class BlockReplacedBox(ReplacedBox, BlockLevelBox): """A box that is both replaced and block-level. A replaced element with a ``display`` value of ``block``, ``liste-item`` or ``table`` generates a block-level replaced box. """ class InlineReplacedBox(ReplacedBox, AtomicInlineLevelBox): """A box that is both replaced and inline-level. A replaced element with a ``display`` value of ``inline``, ``inline-table``, or ``inline-block`` generates an inline-level replaced box. """ class TableBox(BlockLevelBox, ParentBox): """Box for elements with ``display: table``""" # Definitions for the rules generating anonymous table boxes # https://www.w3.org/TR/CSS21/tables.html#anonymous-boxes tabular_container = True def all_children(self): return itertools.chain(self.children, self.column_groups) def translate(self, dx=0, dy=0, ignore_floats=False): self.column_positions = [ position + dx for position in self.column_positions] return super().translate(dx, dy, ignore_floats) def page_values(self): return (self.style['page'], self.style['page']) class InlineTableBox(TableBox): """Box for elements with ``display: inline-table``""" class TableRowGroupBox(ParentBox): """Box for elements with ``display: table-row-group``""" proper_table_child = True internal_table_or_caption = True tabular_container = True proper_parents = (TableBox, InlineTableBox) # Default values. May be overriden on instances. is_header = False is_footer = False class TableRowBox(ParentBox): """Box for elements with ``display: table-row``""" proper_table_child = True internal_table_or_caption = True tabular_container = True proper_parents = (TableBox, InlineTableBox, TableRowGroupBox) class TableColumnGroupBox(ParentBox): """Box for elements with ``display: table-column-group``""" proper_table_child = True internal_table_or_caption = True proper_parents = (TableBox, InlineTableBox) # Columns groups never have margins or paddings margin_top = 0 margin_bottom = 0 margin_left = 0 margin_right = 0 padding_top = 0 padding_bottom = 0 padding_left = 0 padding_right = 0 def get_cells(self): """Return cells that originate in the group's columns.""" return [ cell for column in self.children for cell in column.get_cells()] @property def span(self): if self.children: return len(self.children) else: try: return max(int(self.element.get('span', '').strip()), 1) except ValueError: return 1 # Not really a parent box, but pretending to be removes some corner cases. class TableColumnBox(ParentBox): """Box for elements with ``display: table-column``""" proper_table_child = True internal_table_or_caption = True proper_parents = (TableBox, InlineTableBox, TableColumnGroupBox) # Columns never have margins or paddings margin_top = 0 margin_bottom = 0 margin_left = 0 margin_right = 0 padding_top = 0 padding_bottom = 0 padding_left = 0 padding_right = 0 def get_cells(self): """Return cells that originate in the column. Is set on instances. """ raise NotImplementedError @property def span(self): try: return max(int(self.element.get('span', '').strip()), 1) except ValueError: return 1 class TableCellBox(BlockContainerBox): """Box for elements with ``display: table-cell``""" internal_table_or_caption = True def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # HTML 4.01 gives special meaning to colspan=0 # https://www.w3.org/TR/html401/struct/tables.html#adef-rowspan # but HTML 5 removed it # https://html.spec.whatwg.org/multipage/tables.html#attr-tdth-colspan # rowspan=0 is still there though. try: self.colspan = max(int(self.element.get('colspan', '').strip()), 1) except (AttributeError, ValueError): self.colspan = 1 try: self.rowspan = max(int(self.element.get('rowspan', '').strip()), 0) except (AttributeError, ValueError): self.rowspan = 1 class TableCaptionBox(BlockBox): """Box for elements with ``display: table-caption``""" proper_table_child = True internal_table_or_caption = True proper_parents = (TableBox, InlineTableBox) class PageBox(ParentBox): """Box for a page. Initially the whole document will be in the box for the root element. During layout a new page box is created after every page break. """ def __init__(self, page_type, style): self.page_type = page_type # Page boxes are not linked to any element. super().__init__( element_tag=None, style=style, element=None, children=[]) def __repr__(self): return f'<{type(self).__name__} {self.page_type}>' @property def bleed(self): return { side: self.style[f'bleed_{side}'].value for side in ('top', 'right', 'bottom', 'left')} @property def bleed_area(self): return ( -self.bleed['left'], -self.bleed['top'], self.margin_width() + self.bleed['left'] + self.bleed['right'], self.margin_height() + self.bleed['top'] + self.bleed['bottom']) class MarginBox(BlockContainerBox): """Box in page margins, as defined in CSS3 Paged Media""" def __init__(self, at_keyword, style): self.at_keyword = at_keyword # Margin boxes are not linked to any element. super().__init__( element_tag=None, style=style, element=None, children=[]) def __repr__(self): return f'<{type(self).__name__} {self.at_keyword}>' class FootnoteAreaBox(BlockBox): """Box displaying footnotes, as defined in GCPM.""" def __init__(self, page, style): self.page = page # Footnote area boxes are not linked to any element. super().__init__( element_tag=None, style=style, element=None, children=[]) def __repr__(self): return f'<{type(self).__name__} @footnote>' class FlexContainerBox(ParentBox): """A box that contains only flex-items.""" class FlexBox(FlexContainerBox, BlockLevelBox): """A box that is both block-level and a flex container. It behaves as block on the outside and as a flex container on the inside. """ class InlineFlexBox(FlexContainerBox, InlineLevelBox): """A box that is both inline-level and a flex container. It behaves as inline on the outside and as a flex container on the inside. """ class GridContainerBox(ParentBox): """A box that contains only grid-items.""" class GridBox(GridContainerBox, BlockLevelBox): """A box that is both block-level and a grid container. It behaves as block on the outside and as a grid container on the inside. """ class InlineGridBox(GridContainerBox, InlineLevelBox): """A box that is both inline-level and a grid container. It behaves as inline on the outside and as a grid container on the inside. """