feat: add comprehensive GitHub workflow and development tools

This commit is contained in:
Stiftung Development
2025-09-06 18:31:54 +02:00
commit ab23d7187e
10224 changed files with 2075210 additions and 0 deletions

View File

@@ -0,0 +1,384 @@
"""Transform a "before layout" box tree into an "after layout" tree.
Break boxes across lines and pages; determine the size and dimension of each
box fragement.
Boxes in the new tree have *used values* in their ``position_x``,
``position_y``, ``width`` and ``height`` attributes, amongst others.
See https://www.w3.org/TR/CSS21/cascade.html#used-value
"""
from collections import defaultdict
from functools import partial
from math import inf
from ..formatting_structure import boxes, build
from ..logger import PROGRESS_LOGGER
from .absolute import absolute_box_layout, absolute_layout
from .background import layout_backgrounds
from .block import block_level_layout
from .page import make_all_pages, make_margin_boxes
def initialize_page_maker(context, root_box):
"""Initialize ``context.page_maker``.
Collect the pagination's states required for page based counters.
"""
context.page_maker = []
# Special case the root box
page_break = root_box.style['break_before']
# TODO: take care of text direction and writing mode
# https://www.w3.org/TR/css-page-3/#progression
if page_break == 'right':
right_page = True
elif page_break == 'left':
right_page = False
elif page_break == 'recto':
right_page = root_box.style['direction'] == 'ltr'
elif page_break == 'verso':
right_page = root_box.style['direction'] == 'rtl'
else:
right_page = root_box.style['direction'] == 'ltr'
resume_at = None
next_page = {'break': 'any', 'page': root_box.page_values()[0]}
# page_state is prerequisite for filling in missing page based counters
# although neither a variable quote_depth nor counter_scopes are needed
# in page-boxes -- reusing
# `formatting_structure.build.update_counters()` to avoid redundant
# code requires a full `state`.
# The value of **pages**, of course, is unknown until we return and
# might change when 'content_changed' triggers re-pagination...
# So we start with an empty state
page_state = (
# Shared mutable objects:
[0], # quote_depth: single integer
{'pages': [0]},
[{'pages'}] # counter_scopes
)
# Initial values
remake_state = {
'content_changed': False,
'pages_wanted': False,
'anchors': [], # first occurrence of anchor
'content_lookups': [] # first occurr. of content-CounterLookupItem
}
context.page_maker.append((
resume_at, next_page, right_page, page_state, remake_state))
def layout_fixed_boxes(context, pages, containing_page):
"""Lay out and yield fixed boxes of ``pages`` on ``containing_page``."""
for page in pages:
for box in page.fixed_boxes:
# As replaced boxes are never copied during layout, ensure that we
# have different boxes (with a possibly different layout) for
# each pages.
if isinstance(box, boxes.ReplacedBox):
box = box.copy()
# Absolute boxes in fixed boxes are rendered as fixed boxes'
# children, even when they are fixed themselves.
absolute_boxes = []
absolute_box, _ = absolute_box_layout(
context, box, containing_page, absolute_boxes,
bottom_space=-inf, skip_stack=None)
yield absolute_box
while absolute_boxes:
new_absolute_boxes = []
for box in absolute_boxes:
absolute_layout(
context, box, containing_page, new_absolute_boxes,
bottom_space=-inf, skip_stack=None)
absolute_boxes = new_absolute_boxes
def layout_document(html, root_box, context, max_loops=8):
"""Lay out the whole document.
This includes line breaks, page breaks, absolute size and position for all
boxes. Page based counters might require multiple passes.
:param root_box:
Root of the box tree (formatting structure of the HTML). The page boxes
are created from that tree, this structure is not lost during
pagination.
:returns:
A list of laid out Page objects.
"""
initialize_page_maker(context, root_box)
pages = []
original_footnotes = []
actual_total_pages = 0
for loop in range(max_loops):
if loop > 0:
PROGRESS_LOGGER.info(
'Step 5 - Creating layout - Repagination #%d', loop)
context.footnotes = original_footnotes.copy()
initial_total_pages = actual_total_pages
if loop == 0:
original_footnotes = context.footnotes.copy()
pages = list(make_all_pages(context, root_box, html, pages))
actual_total_pages = len(pages)
# Check whether another round is required
reloop_content = False
reloop_pages = False
for page_data in context.page_maker:
# Update pages
_, _, _, page_state, remake_state = page_data
page_counter_values = page_state[1]
page_counter_values['pages'] = [actual_total_pages]
if remake_state['content_changed']:
reloop_content = True
if remake_state['pages_wanted']:
reloop_pages = initial_total_pages != actual_total_pages
# No need for another loop, stop here
if not reloop_content and not reloop_pages:
break
# Calculate string-sets and bookmark-labels containing page based counters
# when pagination is finished. No need to do that (maybe multiple times) in
# make_page because they dont create boxes, only appear in MarginBoxes and
# in the final PDF.
# Prevent repetition of bookmarks (see #1145).
watch_elements = []
watch_elements_before = []
watch_elements_after = []
for i, page in enumerate(pages):
# We need the updated page_counter_values
_, _, _, page_state, _ = context.page_maker[i + 1]
page_counter_values = page_state[1]
for child in page.descendants():
# Only one bookmark per original box
if child.bookmark_label:
if child.element_tag.endswith('::before'):
checklist = watch_elements_before
elif child.element_tag.endswith('::after'):
checklist = watch_elements_after
else:
checklist = watch_elements
if child.element in checklist:
child.bookmark_label = ''
else:
checklist.append(child.element)
if child.missing_link:
for (box, css_token), item in (
context.target_collector.counter_lookup_items.items()):
if child.missing_link == box and css_token != 'content':
if (css_token == 'bookmark-label' and
not child.bookmark_label):
# don't refill it!
continue
item.parse_again(page_counter_values)
# string_set is a pointer, but the bookmark_label is
# just a string: copy it
if css_token == 'bookmark-label':
child.bookmark_label = box.bookmark_label
# Collect the string_sets in the LayoutContext
string_sets = child.string_set
if string_sets and string_sets != 'none':
for string_set in string_sets:
string_name, text = string_set
context.string_set[string_name][i+1].append(text)
# Add margin boxes
for i, page in enumerate(pages):
root_children = []
root, footnote_area = page.children
root_children.extend(layout_fixed_boxes(context, pages[:i], page))
root_children.extend(root.children)
root_children.extend(layout_fixed_boxes(context, pages[i + 1:], page))
root.children = root_children
context.current_page = i + 1 # page_number starts at 1
# page_maker's page_state is ready for the MarginBoxes
state = context.page_maker[context.current_page][3]
page.children = (root,)
if footnote_area.children:
page.children += (footnote_area,)
page.children += tuple(make_margin_boxes(context, page, state))
layout_backgrounds(page, context.get_image_from_uri)
yield page
class LayoutContext:
def __init__(self, style_for, get_image_from_uri, font_config,
counter_style, target_collector):
self.style_for = style_for
self.get_image_from_uri = partial(get_image_from_uri, context=self)
self.font_config = font_config
self.counter_style = counter_style
self.target_collector = target_collector
self._excluded_shapes_lists = []
self.footnotes = []
self.page_footnotes = {}
self.current_page_footnotes = []
self.reported_footnotes = []
self.current_footnote_area = None # Not initialized yet
self.excluded_shapes = None # Not initialized yet
self.page_bottom = None
self.string_set = defaultdict(lambda: defaultdict(lambda: []))
self.running_elements = defaultdict(lambda: defaultdict(lambda: []))
self.current_page = None
self.forced_break = False
self.broken_out_of_flow = {}
self.in_column = False
# Cache
self.strut_layouts = {}
self.font_features = {}
self.tables = {}
self.dictionaries = {}
def overflows_page(self, bottom_space, position_y):
return self.overflows(self.page_bottom - bottom_space, position_y)
@staticmethod
def overflows(bottom, position_y):
# Use a small fudge factor to avoid floating numbers errors.
# The 1e-9 value comes from PEP 485.
return position_y > bottom * (1 + 1e-9)
def create_block_formatting_context(self):
self.excluded_shapes = []
self._excluded_shapes_lists.append(self.excluded_shapes)
def finish_block_formatting_context(self, root_box):
# See https://www.w3.org/TR/CSS2/visudet.html#root-height
if root_box.style['height'] == 'auto' and self.excluded_shapes:
box_bottom = root_box.content_box_y() + root_box.height
max_shape_bottom = max([
shape.position_y + shape.margin_height()
for shape in self.excluded_shapes] + [box_bottom])
root_box.height += max_shape_bottom - box_bottom
self._excluded_shapes_lists.pop()
if self._excluded_shapes_lists:
self.excluded_shapes = self._excluded_shapes_lists[-1]
else:
self.excluded_shapes = None
def get_string_set_for(self, page, name, keyword='first'):
"""Resolve value of string function."""
return self.get_string_or_element_for(
self.string_set, page, name, keyword)
def get_running_element_for(self, page, name, keyword='first'):
"""Resolve value of element function."""
return self.get_string_or_element_for(
self.running_elements, page, name, keyword)
def get_string_or_element_for(self, store, page, name, keyword):
"""Resolve value of string or element function.
We'll have something like this that represents all assignments on a
given page:
{1: ['First Header'], 3: ['Second Header'],
4: ['Third Header', '3.5th Header']}
Value depends on current page.
https://drafts.csswg.org/css-gcpm/#funcdef-string
:param dict store:
Dictionary where the resolved value is stored.
:param page:
Current page.
:param str name:
Name of the named string or running element.
:param str keyword:
Indicates which value of the named string or running element to
use. Default is the first assignment on the current page else the
most recent assignment.
:returns:
Text for string set, box for running element.
"""
if self.current_page in store[name]:
# A value was assigned on this page
first_string = store[name][self.current_page][0]
last_string = store[name][self.current_page][-1]
if keyword == 'first':
return first_string
elif keyword == 'start':
element = page
while element:
if element.style['string_set'] != 'none':
for (string_name, _) in element.style['string_set']:
if string_name == name:
return first_string
if element.children:
element = element.children[0]
continue
break
elif keyword == 'last':
return last_string
elif keyword == 'first-except':
return
# Search backwards through previous pages
for previous_page in range(self.current_page - 1, 0, -1):
if previous_page in store[name]:
return store[name][previous_page][-1]
def layout_footnote(self, footnote):
"""Add a footnote to the layout for this page."""
self.footnotes.remove(footnote)
self.current_page_footnotes.append(footnote)
return self._update_footnote_area()
def unlayout_footnote(self, footnote):
"""Remove a footnote from the layout and return it to the waitlist."""
# TODO: Handle unlayouting a footnote that hasn't been laid out yet or
# has already been unlayouted
if footnote not in self.footnotes:
self.footnotes.append(footnote)
if footnote in self.current_page_footnotes:
self.current_page_footnotes.remove(footnote)
elif footnote in self.reported_footnotes:
self.reported_footnotes.remove(footnote)
self._update_footnote_area()
def report_footnote(self, footnote):
"""Mark a footnote as being moved to the next page."""
self.current_page_footnotes.remove(footnote)
self.reported_footnotes.append(footnote)
self._update_footnote_area()
def _update_footnote_area(self):
"""Update the page bottom size and our footnote area height."""
if self.current_footnote_area.height != 'auto' and not self.in_column:
self.page_bottom += self.current_footnote_area.margin_height()
self.current_footnote_area.children = self.current_page_footnotes
if self.current_footnote_area.children:
footnote_area = build.create_anonymous_boxes(
self.current_footnote_area.deepcopy())
footnote_area = block_level_layout(
self, footnote_area, -inf, None,
self.current_footnote_area.page)[0]
self.current_footnote_area.height = footnote_area.height
if not self.in_column:
self.page_bottom -= footnote_area.margin_height()
last_child = footnote_area.children[-1]
overflow = (
last_child.position_y + last_child.margin_height() >
footnote_area.position_y + footnote_area.margin_height() -
footnote_area.margin_bottom)
return overflow
else:
self.current_footnote_area.height = 0
if not self.in_column:
self.page_bottom -= self.current_footnote_area.margin_height()
return False

View File

@@ -0,0 +1,344 @@
"""Absolutely positioned boxes management."""
from ..formatting_structure import boxes
from .min_max import handle_min_max_width
from .percent import resolve_percentages, resolve_position_percentages
from .preferred import shrink_to_fit
from .replaced import inline_replaced_box_width_height
from .table import table_wrapper_width
class AbsolutePlaceholder:
"""Left where an absolutely-positioned box was taken out of the flow."""
def __init__(self, box):
assert not isinstance(box, AbsolutePlaceholder)
# Work around the overloaded __setattr__
object.__setattr__(self, '_box', box)
object.__setattr__(self, '_layout_done', False)
def set_laid_out_box(self, new_box):
object.__setattr__(self, '_box', new_box)
object.__setattr__(self, '_layout_done', True)
def translate(self, dx=0, dy=0, ignore_floats=False):
if dx == dy == 0:
return
if self._layout_done:
self._box.translate(dx, dy, ignore_floats)
else:
# Descendants do not have a position yet.
self._box.position_x += dx
self._box.position_y += dy
def copy(self):
new_placeholder = AbsolutePlaceholder(self._box.copy())
object.__setattr__(new_placeholder, '_layout_done', self._layout_done)
return new_placeholder
# Pretend to be the box itself
def __getattr__(self, name):
return getattr(self._box, name)
def __setattr__(self, name, value):
setattr(self._box, name, value)
def __repr__(self):
return '<Placeholder %r>' % self._box
@handle_min_max_width
def absolute_width(box, context, cb_x, cb_y, cb_width, cb_height):
# https://www.w3.org/TR/CSS2/visudet.html#abs-replaced-width
ltr = (
box.style.parent_style is None or
box.style.parent_style['direction'] == 'ltr')
paddings_borders = (
box.padding_left + box.padding_right +
box.border_left_width + box.border_right_width)
translate_x = 0
translate_box_width = False
default_translate_x = cb_x - box.position_x
if box.left == box.right == box.width == 'auto':
if box.margin_left == 'auto':
box.margin_left = 0
if box.margin_right == 'auto':
box.margin_right = 0
available_width = cb_width - (
paddings_borders + box.margin_left + box.margin_right)
box.width = shrink_to_fit(context, box, available_width)
if not ltr:
translate_box_width = True
translate_x = default_translate_x + available_width
elif box.left != 'auto' and box.right != 'auto' and box.width != 'auto':
width_for_margins = cb_width - (
box.right + box.left + box.width + paddings_borders)
if box.margin_left == box.margin_right == 'auto':
if box.width + paddings_borders + box.right + box.left <= cb_width:
box.margin_left = box.margin_right = width_for_margins / 2
else:
box.margin_left = 0 if ltr else width_for_margins
box.margin_right = width_for_margins if ltr else 0
elif box.margin_left == 'auto':
box.margin_left = width_for_margins
elif box.margin_right == 'auto':
box.margin_right = width_for_margins
elif ltr:
box.margin_right = width_for_margins
else:
box.margin_left = width_for_margins
translate_x = box.left + default_translate_x
else:
if box.margin_left == 'auto':
box.margin_left = 0
if box.margin_right == 'auto':
box.margin_right = 0
spacing = paddings_borders + box.margin_left + box.margin_right
if box.left == box.width == 'auto':
box.width = shrink_to_fit(
context, box, cb_width - spacing - box.right)
translate_x = cb_width - box.right - spacing + default_translate_x
translate_box_width = True
elif box.left == box.right == 'auto':
if not ltr:
available_width = cb_width - (
paddings_borders + box.margin_left + box.margin_right)
translate_box_width = True
translate_x = default_translate_x + available_width
elif box.width == box.right == 'auto':
box.width = shrink_to_fit(
context, box, cb_width - spacing - box.left)
translate_x = box.left + default_translate_x
elif box.left == 'auto':
translate_x = cb_width + default_translate_x - (
box.right + spacing + box.width)
elif box.width == 'auto':
box.width = cb_width - box.right - box.left - spacing
translate_x = box.left + default_translate_x
elif box.right == 'auto':
translate_x = box.left + default_translate_x
return translate_box_width, translate_x
def absolute_height(box, context, cb_x, cb_y, cb_width, cb_height):
# https://www.w3.org/TR/CSS2/visudet.html#abs-non-replaced-height
paddings_borders = (
box.padding_top + box.padding_bottom +
box.border_top_width + box.border_bottom_width)
translate_y = 0
translate_box_height = False
default_translate_y = cb_y - box.position_y
if box.top == box.bottom == box.height == 'auto':
# Keep the static position
if box.margin_top == 'auto':
box.margin_top = 0
if box.margin_bottom == 'auto':
box.margin_bottom = 0
elif 'auto' not in (box.top, box.bottom, box.height):
height_for_margins = cb_height - (
box.top + box.bottom + box.height + paddings_borders)
if box.margin_top == box.margin_bottom == 'auto':
box.margin_top = box.margin_bottom = height_for_margins / 2
elif box.margin_top == 'auto':
box.margin_top = height_for_margins
elif box.margin_bottom == 'auto':
box.margin_bottom = height_for_margins
else:
box.margin_bottom = height_for_margins
translate_y = box.top + default_translate_y
else:
if box.margin_top == 'auto':
box.margin_top = 0
if box.margin_bottom == 'auto':
box.margin_bottom = 0
spacing = paddings_borders + box.margin_top + box.margin_bottom
if box.top == box.height == 'auto':
translate_y = (
cb_height - box.bottom - spacing + default_translate_y)
translate_box_height = True
elif box.top == box.bottom == 'auto':
pass # Keep the static position
elif box.height == box.bottom == 'auto':
translate_y = box.top + default_translate_y
elif box.top == 'auto':
translate_y = cb_height + default_translate_y - (
box.bottom + spacing + box.height)
elif box.height == 'auto':
box.height = cb_height - box.bottom - box.top - spacing
translate_y = box.top + default_translate_y
elif box.bottom == 'auto':
translate_y = box.top + default_translate_y
return translate_box_height, translate_y
def absolute_block(context, box, containing_block, fixed_boxes, bottom_space,
skip_stack, cb_x, cb_y, cb_width, cb_height):
from .block import block_container_layout
from .flex import flex_layout
from .grid import grid_layout
translate_box_width, translate_x = absolute_width(
box, context, cb_x, cb_y, cb_width, cb_height)
if skip_stack:
translate_box_height, translate_y = False, 0
else:
translate_box_height, translate_y = absolute_height(
box, context, cb_x, cb_y, cb_width, cb_height)
bottom_space += -box.position_y if translate_box_height else translate_y
# This box is the containing block for absolute descendants.
absolute_boxes = []
if box.is_table_wrapper:
table_wrapper_width(context, box, (cb_width, cb_height))
if isinstance(box, (boxes.BlockBox)):
new_box, resume_at, _, _, _, _ = block_container_layout(
context, box, bottom_space, skip_stack, page_is_empty=True,
absolute_boxes=absolute_boxes, fixed_boxes=fixed_boxes,
adjoining_margins=None, discard=False, max_lines=None)
elif isinstance(box, (boxes.FlexContainerBox)):
new_box, resume_at, _, _, _ = flex_layout(
context, box, bottom_space, skip_stack, containing_block,
page_is_empty=True, absolute_boxes=absolute_boxes,
fixed_boxes=fixed_boxes)
elif isinstance(box, (boxes.GridContainerBox)):
new_box, resume_at, _, _, _ = grid_layout(
context, box, bottom_space, skip_stack, containing_block,
page_is_empty=True, absolute_boxes=absolute_boxes,
fixed_boxes=fixed_boxes)
for child_placeholder in absolute_boxes:
absolute_layout(
context, child_placeholder, new_box, fixed_boxes, bottom_space,
skip_stack=None)
if translate_box_width:
translate_x -= new_box.width
if translate_box_height:
translate_y -= new_box.height
new_box.translate(translate_x, translate_y)
return new_box, resume_at
def absolute_layout(context, placeholder, containing_block, fixed_boxes,
bottom_space, skip_stack):
"""Set the width of absolute positioned ``box``."""
assert not placeholder._layout_done
box = placeholder._box
new_box, resume_at = absolute_box_layout(
context, box, containing_block, fixed_boxes, bottom_space, skip_stack)
placeholder.set_laid_out_box(new_box)
if resume_at:
context.broken_out_of_flow[placeholder] = (
box, containing_block, resume_at)
def absolute_box_layout(context, box, containing_block, fixed_boxes,
bottom_space, skip_stack):
# TODO: handle inline boxes (point 10.1.4.1)
# https://www.w3.org/TR/CSS2/visudet.html#containing-block-details
if isinstance(containing_block, boxes.PageBox):
cb_x = containing_block.content_box_x()
cb_y = containing_block.content_box_y()
cb_width = containing_block.width
cb_height = containing_block.height
else:
cb_x = containing_block.padding_box_x()
cb_y = containing_block.padding_box_y()
cb_width = containing_block.padding_width()
cb_height = containing_block.padding_height()
resolve_percentages(box, (cb_width, cb_height))
resolve_position_percentages(box, (cb_width, cb_height))
context.create_block_formatting_context()
if isinstance(box, boxes.BlockReplacedBox):
new_box = absolute_replaced(
context, box, cb_x, cb_y, cb_width, cb_height)
resume_at = None
else:
# Absolute tables are wrapped into block boxes
new_box, resume_at = absolute_block(
context, box, containing_block, fixed_boxes, bottom_space,
skip_stack, cb_x, cb_y, cb_width, cb_height)
context.finish_block_formatting_context(new_box)
return new_box, resume_at
def absolute_replaced(context, box, cb_x, cb_y, cb_width, cb_height):
inline_replaced_box_width_height(box, (cb_x, cb_y, cb_width, cb_height))
ltr = (
box.style.parent_style is None or
box.style.parent_style['direction'] == 'ltr')
# https://www.w3.org/TR/CSS21/visudet.html#abs-replaced-width
if box.left == box.right == 'auto':
# static position:
if ltr:
box.left = box.position_x - cb_x
else:
box.right = cb_x + cb_width - box.position_x
if 'auto' in (box.left, box.right):
if box.margin_left == 'auto':
box.margin_left = 0
if box.margin_right == 'auto':
box.margin_right = 0
remaining = cb_width - box.margin_width()
if box.left == 'auto':
box.left = remaining - box.right
if box.right == 'auto':
box.right = remaining - box.left
elif 'auto' in (box.margin_left, box.margin_right):
remaining = cb_width - (box.border_width() + box.left + box.right)
if box.margin_left == box.margin_right == 'auto':
if remaining >= 0:
box.margin_left = box.margin_right = remaining // 2
else:
box.margin_left = 0 if ltr else remaining
box.margin_right = remaining if ltr else 0
elif box.margin_left == 'auto':
box.margin_left = remaining
else:
box.margin_right = remaining
else:
# Over-constrained
if ltr:
box.right = cb_width - (box.margin_width() + box.left)
else:
box.left = cb_width - (box.margin_width() + box.right)
# https://www.w3.org/TR/CSS21/visudet.html#abs-replaced-height
if box.top == box.bottom == 'auto':
box.top = box.position_y - cb_y
if 'auto' in (box.top, box.bottom):
if box.margin_top == 'auto':
box.margin_top = 0
if box.margin_bottom == 'auto':
box.margin_bottom = 0
remaining = cb_height - box.margin_height()
if box.top == 'auto':
box.top = remaining - box.bottom
if box.bottom == 'auto':
box.bottom = remaining - box.top
elif 'auto' in (box.margin_top, box.margin_bottom):
remaining = cb_height - (box.border_height() + box.top + box.bottom)
if box.margin_top == box.margin_bottom == 'auto':
box.margin_top = box.margin_bottom = remaining // 2
elif box.margin_top == 'auto':
box.margin_top = remaining
else:
box.margin_bottom = remaining
else:
# Over-constrained
box.bottom = cb_height - (box.margin_height() + box.top)
# No children for replaced boxes, no need to .translate()
box.position_x = cb_x + box.left
box.position_y = cb_y + box.top
return box

View File

@@ -0,0 +1,248 @@
"""Manage background position and size."""
from collections import namedtuple
from itertools import cycle
from tinycss2.color3 import parse_color
from ..formatting_structure import boxes
from . import replaced
from .percent import percentage, resolve_radii_percentages
Background = namedtuple('Background', 'color, layers, image_rendering')
BackgroundLayer = namedtuple(
'BackgroundLayer',
'image, size, position, repeat, unbounded, '
'painting_area, positioning_area, clipped_boxes')
def box_rectangle(box, which_rectangle):
if which_rectangle == 'border-box':
return (
box.border_box_x(), box.border_box_y(),
box.border_width(), box.border_height())
elif which_rectangle == 'padding-box':
return (
box.padding_box_x(), box.padding_box_y(),
box.padding_width(), box.padding_height())
else:
assert which_rectangle == 'content-box', which_rectangle
return (
box.content_box_x(), box.content_box_y(),
box.width, box.height)
def layout_box_backgrounds(page, box, get_image_from_uri, layout_children=True,
style=None):
"""Fetch and position background images."""
from ..draw import get_color
# Resolve percentages in border-radius properties
resolve_radii_percentages(box)
if layout_children:
for child in box.all_children():
layout_box_backgrounds(page, child, get_image_from_uri)
if style is None:
style = box.style
# This is for the border image, not the background, but this is a
# convenient place to get the image.
if style['border_image_source'][0] != 'none':
type_, value = style['border_image_source']
if type_ == 'url':
box.border_image = get_image_from_uri(url=value)
else:
box.border_image = value
if style['visibility'] == 'hidden':
images = []
color = parse_color('transparent')
else:
orientation = style['image_orientation']
images = [
get_image_from_uri(url=value, orientation=orientation)
if type_ == 'url' else value
for type_, value in style['background_image']]
color = get_color(style, 'background_color')
if color.alpha == 0 and not any(images):
if box != page: # Pages need a background for bleed box
box.background = None
return
layers = [
layout_background_layer(box, page, style['image_resolution'], *layer)
for layer in zip(images, *map(cycle, [
style['background_size'],
style['background_clip'],
style['background_repeat'],
style['background_origin'],
style['background_position'],
style['background_attachment']]))]
box.background = Background(color, layers, style['image_rendering'])
def layout_background_layer(box, page, resolution, image, size, clip, repeat,
origin, position, attachment):
# TODO: respect box-sizing for table cells?
clipped_boxes = []
painting_area = 0, 0, 0, 0
if box is page:
# [The pages] background painting area is the bleed area […]
# regardless of background-clip.
# https://drafts.csswg.org/css-page-3/#painting
painting_area = page.bleed_area
clipped_boxes = []
elif isinstance(box, boxes.TableRowGroupBox):
clipped_boxes = []
total_height = 0
for row in box.children:
if row.children:
clipped_boxes += [
cell.rounded_border_box() for cell in row.children]
total_height = max(total_height, max(
cell.border_height() for cell in row.children))
painting_area = [
box.border_box_x(), box.border_box_y(),
box.border_width(), total_height]
elif isinstance(box, boxes.TableRowBox):
if box.children:
clipped_boxes = [
cell.rounded_border_box() for cell in box.children]
height = max(cell.border_height() for cell in box.children)
painting_area = [
box.border_box_x(), box.border_box_y(),
box.border_width(), height]
elif isinstance(box, (boxes.TableColumnGroupBox, boxes.TableColumnBox)):
cells = box.get_cells()
if cells:
clipped_boxes = [cell.rounded_border_box() for cell in cells]
min_x = min(cell.border_box_x() for cell in cells)
max_x = max(
cell.border_box_x() + cell.border_width() for cell in cells)
painting_area = [
min_x, box.border_box_y(), max_x - min_x, box.border_height()]
else:
painting_area = box_rectangle(box, clip)
if clip == 'border-box':
clipped_boxes = [box.rounded_border_box()]
elif clip == 'padding-box':
clipped_boxes = [box.rounded_padding_box()]
else:
assert clip == 'content-box', clip
clipped_boxes = [box.rounded_content_box()]
if image is not None:
intrinsic_width, intrinsic_height, ratio = image.get_intrinsic_size(
resolution, box.style['font_size'])
if image is None or 0 in (intrinsic_width, intrinsic_height):
return BackgroundLayer(
image=None, unbounded=False, painting_area=painting_area,
size='unused', position='unused', repeat='unused',
positioning_area='unused', clipped_boxes=clipped_boxes)
if attachment == 'fixed':
# Initial containing block
if isinstance(box, boxes.PageBox):
# […] if background-attachment is fixed then the image is
# positioned relative to the page box including its margins […].
# https://drafts.csswg.org/css-page/#painting
positioning_area = (0, 0, box.margin_width(), box.margin_height())
else:
positioning_area = box_rectangle(page, 'content-box')
else:
positioning_area = box_rectangle(box, origin)
positioning_x, positioning_y, positioning_width, positioning_height = (
positioning_area)
painting_x, painting_y, painting_width, painting_height = painting_area
if size == 'cover':
image_width, image_height = replaced.cover_constraint_image_sizing(
positioning_width, positioning_height, ratio)
elif size == 'contain':
image_width, image_height = replaced.contain_constraint_image_sizing(
positioning_width, positioning_height, ratio)
else:
size_width, size_height = size
image_width, image_height = replaced.default_image_sizing(
intrinsic_width, intrinsic_height, ratio,
percentage(size_width, positioning_width),
percentage(size_height, positioning_height),
positioning_width, positioning_height)
origin_x, position_x, origin_y, position_y = position
ref_x = positioning_width - image_width
ref_y = positioning_height - image_height
position_x = percentage(position_x, ref_x)
position_y = percentage(position_y, ref_y)
if origin_x == 'right':
position_x = ref_x - position_x
if origin_y == 'bottom':
position_y = ref_y - position_y
repeat_x, repeat_y = repeat
if repeat_x == 'round':
n_repeats = max(1, round(positioning_width / image_width))
new_width = positioning_width / n_repeats
position_x = 0 # Ignore background-position for this dimension
if repeat_y != 'round' and size[1] == 'auto':
image_height *= new_width / image_width
image_width = new_width
if repeat_y == 'round':
n_repeats = max(1, round(positioning_height / image_height))
new_height = positioning_height / n_repeats
position_y = 0 # Ignore background-position for this dimension
if repeat_x != 'round' and size[0] == 'auto':
image_width *= new_height / image_height
image_height = new_height
return BackgroundLayer(
image=image,
size=(image_width, image_height),
position=(position_x, position_y),
repeat=repeat,
unbounded=False,
painting_area=painting_area,
positioning_area=positioning_area,
clipped_boxes=clipped_boxes)
def layout_backgrounds(page, get_image_from_uri):
"""Layout backgrounds on the page box and on its children.
This function takes care of the canvas background, taken from the root
elememt or a <body> child of the root element.
See https://www.w3.org/TR/CSS21/colors.html#background
"""
layout_box_backgrounds(page, page, get_image_from_uri)
assert not isinstance(page.children[0], boxes.MarginBox)
root_box = page.children[0]
chosen_box = root_box
if root_box.element_tag.lower() == 'html' and root_box.background is None:
for child in root_box.children:
if child.element_tag.lower() == 'body':
chosen_box = child
break
if chosen_box.background:
painting_area = box_rectangle(page, 'border-box')
original_background = page.background
layout_box_backgrounds(
page, page, get_image_from_uri, layout_children=False,
style=chosen_box.style)
page.canvas_background = page.background._replace(
# TODO: background-clip should be updated
layers=[
layer._replace(painting_area=painting_area)
for layer in page.background.layers])
page.background = original_background
chosen_box.background = None
else:
page.canvas_background = None

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,417 @@
"""Layout for columns."""
from math import floor, inf
from .absolute import absolute_layout
from .percent import percentage, 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 ( # isort:skip
block_box_layout, block_level_layout, block_level_width,
collapse_margin, remove_placeholders)
style = box.style
width = style['column_width']
count = style['column_count']
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)
if style['column_gap'] == 'normal':
# 1em because in column context
gap = style['font_size']
else:
gap = percentage(style['column_gap'], box.width)
# 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 = max(
1, 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 doesnt 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:
columns = []
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 theyve 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

View File

@@ -0,0 +1,898 @@
"""Layout for flex containers and flex-items."""
import sys
from math import inf, log10
from ..css.properties import Dimension
from ..formatting_structure import boxes
from .percent import resolve_one_percentage, resolve_percentages
from .preferred import max_content_width, min_content_width
from .table import find_in_flow_baseline
class FlexLine(list):
pass
def flex_layout(context, box, bottom_space, skip_stack, containing_block,
page_is_empty, absolute_boxes, fixed_boxes):
from . import block, preferred
context.create_block_formatting_context()
resume_at = None
# Step 1 is done in formatting_structure.boxes
# Step 2
if box.style['flex_direction'].startswith('row'):
axis, cross = 'width', 'height'
else:
axis, cross = 'height', 'width'
margin_left = 0 if box.margin_left == 'auto' else box.margin_left
margin_right = 0 if box.margin_right == 'auto' else box.margin_right
margin_top = 0 if box.margin_top == 'auto' else box.margin_top
margin_bottom = 0 if box.margin_bottom == 'auto' else box.margin_bottom
if getattr(box, axis) != 'auto':
available_main_space = getattr(box, axis)
else:
if axis == 'width':
available_main_space = (
containing_block.width -
margin_left - margin_right -
box.padding_left - box.padding_right -
box.border_left_width - box.border_right_width)
else:
main_space = context.page_bottom - bottom_space - box.position_y
if containing_block.height != 'auto':
if isinstance(containing_block.height, Dimension):
assert containing_block.height.unit == 'px'
main_space = min(main_space, containing_block.height.value)
else:
main_space = min(main_space, containing_block.height)
available_main_space = (
main_space -
margin_top - margin_bottom -
box.padding_top - box.padding_bottom -
box.border_top_width - box.border_bottom_width)
if getattr(box, cross) != 'auto':
available_cross_space = getattr(box, cross)
else:
if cross == 'height':
main_space = (
context.page_bottom - bottom_space - box.content_box_y())
if containing_block.height != 'auto':
if isinstance(containing_block.height, Dimension):
assert containing_block.height.unit == 'px'
main_space = min(main_space, containing_block.height.value)
else:
main_space = min(main_space, containing_block.height)
available_cross_space = (
main_space -
margin_top - margin_bottom -
box.padding_top - box.padding_bottom -
box.border_top_width - box.border_bottom_width)
else:
available_cross_space = (
containing_block.width -
margin_left - margin_right -
box.padding_left - box.padding_right -
box.border_left_width - box.border_right_width)
# Step 3
children = box.children
parent_box = box.copy_with_children(children)
resolve_percentages(parent_box, containing_block)
# TODO: removing auto margins is OK for this step, but margins should be
# calculated later.
if parent_box.margin_top == 'auto':
box.margin_top = parent_box.margin_top = 0
if parent_box.margin_bottom == 'auto':
box.margin_bottom = parent_box.margin_bottom = 0
if parent_box.margin_left == 'auto':
box.margin_left = parent_box.margin_left = 0
if parent_box.margin_right == 'auto':
box.margin_right = parent_box.margin_right = 0
if isinstance(parent_box, boxes.FlexBox):
block.block_level_width(parent_box, containing_block)
else:
parent_box.width = preferred.flex_max_content_width(
context, parent_box)
original_skip_stack = skip_stack
children = sorted(children, key=lambda item: item.style['order'])
if skip_stack is not None:
(skip, skip_stack), = skip_stack.items()
if box.style['flex_direction'].endswith('-reverse'):
children = children[:skip + 1]
else:
children = children[skip:]
skip_stack = skip_stack
else:
skip_stack = None
child_skip_stack = skip_stack
for child in children:
if not child.is_flex_item:
continue
# See https://www.w3.org/TR/css-flexbox-1/#min-size-auto
if child.style['overflow'] == 'visible':
main_flex_direction = axis
else:
main_flex_direction = None
resolve_percentages(child, containing_block, main_flex_direction)
child.position_x = parent_box.content_box_x()
child.position_y = parent_box.content_box_y()
if child.min_width == 'auto':
specified_size = child.width if child.width != 'auto' else inf
if isinstance(child, boxes.ParentBox):
new_child = child.copy_with_children(child.children)
else:
new_child = child.copy()
new_child.style = child.style.copy()
new_child.style['width'] = 'auto'
new_child.style['min_width'] = Dimension(0, 'px')
new_child.style['max_width'] = Dimension(inf, 'px')
content_size = min_content_width(context, new_child, outer=False)
child.min_width = min(specified_size, content_size)
elif child.min_height == 'auto':
# TODO: find a way to get min-content-height
specified_size = child.height if child.height != 'auto' else inf
if isinstance(child, boxes.ParentBox):
new_child = child.copy_with_children(child.children)
else:
new_child = child.copy()
new_child.style = child.style.copy()
new_child.style['height'] = 'auto'
new_child.style['min_height'] = Dimension(0, 'px')
new_child.style['max_height'] = Dimension(inf, 'px')
new_child = block.block_level_layout(
context, new_child, -inf, child_skip_stack, parent_box,
page_is_empty)[0]
content_size = new_child.height
child.min_height = min(specified_size, content_size)
child.style = child.style.copy()
if child.style['flex_basis'] == 'content':
flex_basis = child.flex_basis = 'content'
else:
resolve_one_percentage(child, 'flex_basis', available_main_space)
flex_basis = child.flex_basis
# "If a value would resolve to auto for width, it instead resolves
# to content for flex-basis." Let's do this for height too.
# See https://www.w3.org/TR/css-flexbox-1/#propdef-flex-basis
resolve_one_percentage(child, axis, available_main_space)
if flex_basis == 'auto':
if child.style[axis] == 'auto':
flex_basis = 'content'
else:
if axis == 'width':
flex_basis = child.border_width()
if child.margin_left != 'auto':
flex_basis += child.margin_left
if child.margin_right != 'auto':
flex_basis += child.margin_right
else:
flex_basis = child.border_height()
if child.margin_top != 'auto':
flex_basis += child.margin_top
if child.margin_bottom != 'auto':
flex_basis += child.margin_bottom
# Step 3.A
if flex_basis != 'content':
child.flex_base_size = flex_basis
# TODO: Step 3.B
# TODO: Step 3.C
# Step 3.D is useless, as we never have infinite sizes on paged media
# Step 3.E
else:
child.style[axis] = 'max-content'
# TODO: don't set style value, support *-content values instead
if child.style[axis] == 'max-content':
child.style[axis] = 'auto'
if axis == 'width':
child.flex_base_size = max_content_width(context, child)
else:
if isinstance(child, boxes.ParentBox):
new_child = child.copy_with_children(child.children)
else:
new_child = child.copy()
new_child.width = inf
new_child = block.block_level_layout(
context, new_child, -inf, child_skip_stack, parent_box,
page_is_empty, absolute_boxes, fixed_boxes)[0]
child.flex_base_size = new_child.margin_height()
elif child.style[axis] == 'min-content':
child.style[axis] = 'auto'
if axis == 'width':
child.flex_base_size = min_content_width(context, child)
else:
if isinstance(child, boxes.ParentBox):
new_child = child.copy_with_children(child.children)
else:
new_child = child.copy()
new_child.width = 0
new_child = block.block_level_layout(
context, new_child, -inf, child_skip_stack, parent_box,
page_is_empty, absolute_boxes, fixed_boxes)[0]
child.flex_base_size = new_child.margin_height()
else:
assert child.style[axis].unit == 'px'
# TODO: should we add padding, borders and margins?
child.flex_base_size = child.style[axis].value
child.hypothetical_main_size = max(
getattr(child, f'min_{axis}'), min(
child.flex_base_size, getattr(child, f'max_{axis}')))
# Skip stack is only for the first child
child_skip_stack = None
# Step 4
# TODO: the whole step has to be fixed
if axis == 'width':
block.block_level_width(box, containing_block)
else:
if box.style['height'] != 'auto' and box.style['height'].unit != '%':
box.height = box.style['height'].value
elif box.style['height'] != 'auto' and containing_block.height != 'auto':
box.height = box.style['height'].value / 100 * containing_block.height
else:
box.height = 0
for i, child in enumerate(children):
if not child.is_flex_item:
continue
child_height = (
child.hypothetical_main_size +
child.border_top_width + child.border_bottom_width +
child.padding_top + child.padding_bottom)
if getattr(box, axis) == 'auto' and (
child_height + box.height > available_main_space):
resume_at = {i: None}
children = children[:i + 1]
break
box.height += child_height
# Step 5
flex_lines = []
line = []
line_size = 0
axis_size = getattr(box, axis)
for i, child in enumerate(children):
if not child.is_flex_item:
continue
line_size += child.hypothetical_main_size
if box.style['flex_wrap'] != 'nowrap' and line_size > axis_size:
if line:
flex_lines.append(FlexLine(line))
line = [(i, child)]
line_size = child.hypothetical_main_size
else:
line.append((i, child))
flex_lines.append(FlexLine(line))
line = []
line_size = 0
else:
line.append((i, child))
if line:
flex_lines.append(FlexLine(line))
# TODO: handle *-reverse using the terminology from the specification
if box.style['flex_wrap'] == 'wrap-reverse':
flex_lines.reverse()
if box.style['flex_direction'].endswith('-reverse'):
for line in flex_lines:
line.reverse()
# Step 6
# See https://www.w3.org/TR/css-flexbox-1/#resolve-flexible-lengths
for line in flex_lines:
# Step 6 - 9.7.1
hypothetical_main_size = sum(
child.hypothetical_main_size for i, child in line)
if hypothetical_main_size < available_main_space:
flex_factor_type = 'grow'
else:
flex_factor_type = 'shrink'
# Step 6 - 9.7.2
for i, child in line:
if flex_factor_type == 'grow':
child.flex_factor = child.style['flex_grow']
else:
child.flex_factor = child.style['flex_shrink']
if (child.flex_factor == 0 or
(flex_factor_type == 'grow' and
child.flex_base_size > child.hypothetical_main_size) or
(flex_factor_type == 'shrink' and
child.flex_base_size < child.hypothetical_main_size)):
child.target_main_size = child.hypothetical_main_size
child.frozen = True
else:
child.frozen = False
# Step 6 - 9.7.3
initial_free_space = available_main_space
for i, child in line:
if child.frozen:
initial_free_space -= child.target_main_size
else:
initial_free_space -= child.flex_base_size
# Step 6 - 9.7.4
while not all(child.frozen for i, child in line):
unfrozen_factor_sum = 0
remaining_free_space = available_main_space
# Step 6 - 9.7.4.b
for i, child in line:
if child.frozen:
remaining_free_space -= child.target_main_size
else:
remaining_free_space -= child.flex_base_size
unfrozen_factor_sum += child.flex_factor
if unfrozen_factor_sum < 1:
initial_free_space *= unfrozen_factor_sum
if initial_free_space == inf:
initial_free_space = sys.maxsize
if remaining_free_space == inf:
remaining_free_space = sys.maxsize
initial_magnitude = (
int(log10(initial_free_space)) if initial_free_space > 0
else -inf)
remaining_magnitude = (
int(log10(remaining_free_space)) if remaining_free_space > 0
else -inf)
if initial_magnitude < remaining_magnitude:
remaining_free_space = initial_free_space
# Step 6 - 9.7.4.c
if remaining_free_space == 0:
# "Do nothing", but we at least set the flex_base_size as
# target_main_size for next step.
for i, child in line:
if not child.frozen:
child.target_main_size = child.flex_base_size
else:
scaled_flex_shrink_factors_sum = 0
flex_grow_factors_sum = 0
for i, child in line:
if not child.frozen:
child.scaled_flex_shrink_factor = (
child.flex_base_size * child.style['flex_shrink'])
scaled_flex_shrink_factors_sum += (
child.scaled_flex_shrink_factor)
flex_grow_factors_sum += child.style['flex_grow']
for i, child in line:
if not child.frozen:
if flex_factor_type == 'grow':
ratio = (
child.style['flex_grow'] /
flex_grow_factors_sum)
child.target_main_size = (
child.flex_base_size +
remaining_free_space * ratio)
elif flex_factor_type == 'shrink':
if scaled_flex_shrink_factors_sum == 0:
child.target_main_size = child.flex_base_size
else:
ratio = (
child.scaled_flex_shrink_factor /
scaled_flex_shrink_factors_sum)
child.target_main_size = (
child.flex_base_size +
remaining_free_space * ratio)
# Step 6 - 9.7.4.d
# TODO: First part of this step is useless until 3.E is correct
for i, child in line:
child.adjustment = 0
if not child.frozen and child.target_main_size < 0:
child.adjustment = -child.target_main_size
child.target_main_size = 0
# Step 6 - 9.7.4.e
adjustments = sum(child.adjustment for i, child in line)
for i, child in line:
if adjustments == 0:
child.frozen = True
elif adjustments > 0 and child.adjustment > 0:
child.frozen = True
elif adjustments < 0 and child.adjustment < 0:
child.frozen = True
# Step 6 - 9.7.5
for i, child in line:
if axis == 'width':
child.width = (
child.target_main_size -
child.padding_left - child.padding_right -
child.border_left_width - child.border_right_width)
if child.margin_left != 'auto':
child.width -= child.margin_left
if child.margin_right != 'auto':
child.width -= child.margin_right
else:
child.height = (
child.target_main_size -
child.padding_top - child.padding_bottom -
child.border_top_width - child.border_top_width)
if child.margin_left != 'auto':
child.height -= child.margin_left
if child.margin_right != 'auto':
child.height -= child.margin_right
# Step 7
# TODO: Fix TODO in build.flex_children
# TODO: Handle breaks
new_flex_lines = []
child_skip_stack = skip_stack
for line in flex_lines:
new_flex_line = FlexLine()
for i, child in line:
# TODO: Find another way than calling block_level_layout_switch to
# get baseline and child.height
if child.margin_top == 'auto':
child.margin_top = 0
if child.margin_bottom == 'auto':
child.margin_bottom = 0
if isinstance(child, boxes.ParentBox):
child_copy = child.copy_with_children(child.children)
else:
child_copy = child.copy()
block.block_level_width(child_copy, parent_box)
new_child, _, _, adjoining_margins, _, _ = (
block.block_level_layout_switch(
context, child_copy, -inf, child_skip_stack, parent_box,
page_is_empty, absolute_boxes, fixed_boxes,
[], False, None))
child._baseline = find_in_flow_baseline(new_child) or 0
if cross == 'height':
child.height = new_child.height
# As flex items margins never collapse (with other flex items
# or with the flex container), we can add the adjoining margins
# to the child bottom margin.
child.margin_bottom += block.collapse_margin(adjoining_margins)
else:
child.width = min_content_width(context, child, outer=False)
new_flex_line.append((i, child))
# Skip stack is only for the first child
child_skip_stack = None
if new_flex_line:
new_flex_lines.append(new_flex_line)
flex_lines = new_flex_lines
# Step 8
cross_size = getattr(box, cross)
if len(flex_lines) == 1 and cross_size != 'auto':
flex_lines[0].cross_size = cross_size
else:
for line in flex_lines:
collected_items = []
not_collected_items = []
for i, child in line:
align_self = child.style['align_self']
if (box.style['flex_direction'].startswith('row') and
'baseline' in align_self and
child.margin_top != 'auto' and
child.margin_bottom != 'auto'):
collected_items.append(child)
else:
not_collected_items.append(child)
cross_start_distance = 0
cross_end_distance = 0
for child in collected_items:
baseline = child._baseline - child.position_y
cross_start_distance = max(cross_start_distance, baseline)
cross_end_distance = max(
cross_end_distance, child.margin_height() - baseline)
collected_cross_size = cross_start_distance + cross_end_distance
non_collected_cross_size = 0
if not_collected_items:
non_collected_cross_size = -inf
for child in not_collected_items:
if cross == 'height':
child_cross_size = child.border_height()
if child.margin_top != 'auto':
child_cross_size += child.margin_top
if child.margin_bottom != 'auto':
child_cross_size += child.margin_bottom
else:
child_cross_size = child.border_width()
if child.margin_left != 'auto':
child_cross_size += child.margin_left
if child.margin_right != 'auto':
child_cross_size += child.margin_right
non_collected_cross_size = max(
child_cross_size, non_collected_cross_size)
line.cross_size = max(
collected_cross_size, non_collected_cross_size)
if len(flex_lines) == 1:
line, = flex_lines
min_cross_size = getattr(box, f'min_{cross}')
if min_cross_size == 'auto':
min_cross_size = -inf
max_cross_size = getattr(box, f'max_{cross}')
if max_cross_size == 'auto':
max_cross_size = inf
line.cross_size = max(
min_cross_size, min(line.cross_size, max_cross_size))
# Step 9
align_content = box.style['align_content']
if 'normal' in align_content:
align_content = ('stretch',)
if 'stretch' in align_content:
definite_cross_size = None
if cross == 'height' and box.style['height'] != 'auto':
definite_cross_size = box.style['height'].value
elif cross == 'width':
if isinstance(box, boxes.FlexBox):
if box.style['width'] == 'auto':
definite_cross_size = available_cross_space
else:
definite_cross_size = box.style['width'].value
if definite_cross_size is not None:
extra_cross_size = definite_cross_size - sum(
line.cross_size for line in flex_lines)
if extra_cross_size:
for line in flex_lines:
line.cross_size += extra_cross_size / len(flex_lines)
# TODO: Step 10
# Step 11
align_items = box.style['align_items']
if 'normal' in align_items:
align_items = ('stretch',)
for line in flex_lines:
for i, child in line:
align_self = child.style['align_self']
if 'normal' in align_self:
align_self = ('stretch',)
elif 'auto' in align_self:
align_self = align_items
if 'stretch' in align_self and child.style[cross] == 'auto':
cross_margins = (
(child.margin_top, child.margin_bottom)
if cross == 'height'
else (child.margin_left, child.margin_right))
if child.style[cross] == 'auto':
if 'auto' not in cross_margins:
cross_size = line.cross_size
if cross == 'height':
cross_size -= (
child.margin_top + child.margin_bottom +
child.padding_top + child.padding_bottom +
child.border_top_width +
child.border_bottom_width)
else:
cross_size -= (
child.margin_left + child.margin_right +
child.padding_left + child.padding_right +
child.border_left_width +
child.border_right_width)
setattr(child, cross, cross_size)
# TODO: redo layout?
# else: Cross size has been set by step 7
# Step 12
original_position_axis = (
box.content_box_x() if axis == 'width'
else box.content_box_y())
justify_content = box.style['justify_content']
if 'normal' in justify_content:
justify_content = ('flex-start',)
if box.style['flex_direction'].endswith('-reverse'):
if 'flex-start' in justify_content:
justify_content = ('flex-end',)
elif justify_content == 'flex-end':
justify_content = ('flex-start',)
for line in flex_lines:
position_axis = original_position_axis
if axis == 'width':
free_space = box.width
for i, child in line:
free_space -= child.border_width()
if child.margin_left != 'auto':
free_space -= child.margin_left
if child.margin_right != 'auto':
free_space -= child.margin_right
else:
free_space = box.height
for i, child in line:
free_space -= child.border_height()
if child.margin_top != 'auto':
free_space -= child.margin_top
if child.margin_bottom != 'auto':
free_space -= child.margin_bottom
margins = 0
for i, child in line:
if axis == 'width':
if child.margin_left == 'auto':
margins += 1
if child.margin_right == 'auto':
margins += 1
else:
if child.margin_top == 'auto':
margins += 1
if child.margin_bottom == 'auto':
margins += 1
if margins:
free_space /= margins
for i, child in line:
if axis == 'width':
if child.margin_left == 'auto':
child.margin_left = free_space
if child.margin_right == 'auto':
child.margin_right = free_space
else:
if child.margin_top == 'auto':
child.margin_top = free_space
if child.margin_bottom == 'auto':
child.margin_bottom = free_space
free_space = 0
if box.style['direction'] == 'rtl' and axis == 'width':
free_space *= -1
if {'end', 'flex-end', 'right'} & set(justify_content):
position_axis += free_space
elif 'center' in justify_content:
position_axis += free_space / 2
elif 'space-around' in justify_content:
position_axis += free_space / len(line) / 2
elif 'space-evenly' in justify_content:
position_axis += free_space / (len(line) + 1)
for i, child in line:
if axis == 'width':
child.position_x = position_axis
if 'stretch' in justify_content:
child.width += free_space / len(line)
else:
child.position_y = position_axis
margin_axis = (
child.margin_width() if axis == 'width'
else child.margin_height())
if box.style['direction'] == 'rtl' and axis == 'width':
margin_axis *= -1
position_axis += margin_axis
if 'space-around' in justify_content:
position_axis += free_space / len(line)
elif 'space-between' in justify_content:
if len(line) > 1:
position_axis += free_space / (len(line) - 1)
elif 'space-evenly' in justify_content:
position_axis += free_space / (len(line) + 1)
# Step 13
position_cross = (
box.content_box_y() if cross == 'height'
else box.content_box_x())
for line in flex_lines:
line.lower_baseline = -inf
# TODO: don't duplicate this loop
for i, child in line:
align_self = child.style['align_self']
if 'auto' in align_self:
align_self = align_items
if 'baseline' in align_self and axis == 'width':
# TODO: handle vertical text
child.baseline = child._baseline - position_cross
line.lower_baseline = max(line.lower_baseline, child.baseline)
if line.lower_baseline == -inf:
line.lower_baseline = line[0][1]._baseline if line else 0
for i, child in line:
cross_margins = (
(child.margin_top, child.margin_bottom) if cross == 'height'
else (child.margin_left, child.margin_right))
auto_margins = sum([margin == 'auto' for margin in cross_margins])
if auto_margins:
extra_cross = line.cross_size
if cross == 'height':
extra_cross -= child.border_height()
if child.margin_top != 'auto':
extra_cross -= child.margin_top
if child.margin_bottom != 'auto':
extra_cross -= child.margin_bottom
else:
extra_cross -= child.border_width()
if child.margin_left != 'auto':
extra_cross -= child.margin_left
if child.margin_right != 'auto':
extra_cross -= child.margin_right
if extra_cross > 0:
extra_cross /= auto_margins
if cross == 'height':
if child.margin_top == 'auto':
child.margin_top = extra_cross
if child.margin_bottom == 'auto':
child.margin_bottom = extra_cross
else:
if child.margin_left == 'auto':
child.margin_left = extra_cross
if child.margin_right == 'auto':
child.margin_right = extra_cross
else:
if cross == 'height':
if child.margin_top == 'auto':
child.margin_top = 0
child.margin_bottom = extra_cross
else:
if child.margin_left == 'auto':
child.margin_left = 0
child.margin_right = extra_cross
else:
# Step 14
align_self = child.style['align_self']
if 'normal' in align_self:
align_self = ('stretch',)
elif 'auto' in align_self:
align_self = align_items
position = 'position_y' if cross == 'height' else 'position_x'
setattr(child, position, position_cross)
if {'end', 'self-end', 'flex-end'} & set(align_self):
if cross == 'height':
child.position_y += (
line.cross_size - child.margin_height())
else:
child.position_x += (
line.cross_size - child.margin_width())
elif 'center' in align_self:
if cross == 'height':
child.position_y += (
line.cross_size - child.margin_height()) / 2
else:
child.position_x += (
line.cross_size - child.margin_width()) / 2
elif 'baseline' in align_self:
if cross == 'height':
child.position_y += (
line.lower_baseline - child.baseline)
else:
# Handle vertical text
pass
elif 'stretch' in align_self:
if child.style[cross] == 'auto':
if cross == 'height':
margins = child.margin_top + child.margin_bottom
else:
margins = child.margin_left + child.margin_right
if child.style['box_sizing'] == 'content-box':
if cross == 'height':
margins += (
child.border_top_width +
child.border_bottom_width +
child.padding_top + child.padding_bottom)
else:
margins += (
child.border_left_width +
child.border_right_width +
child.padding_left + child.padding_right)
# TODO: don't set style width, find a way to avoid
# width re-calculation after Step 16
child.style[cross] = Dimension(
line.cross_size - margins, 'px')
position_cross += line.cross_size
# Step 15
if getattr(box, cross) == 'auto':
# TODO: handle min-max
setattr(box, cross, sum(line.cross_size for line in flex_lines))
# Step 16
elif len(flex_lines) > 1:
extra_cross_size = getattr(box, cross) - sum(
line.cross_size for line in flex_lines)
direction = 'position_y' if cross == 'height' else 'position_x'
if extra_cross_size > 0:
cross_translate = 0
for line in flex_lines:
for i, child in line:
if child.is_flex_item:
current_value = getattr(child, direction)
current_value += cross_translate
setattr(child, direction, current_value)
if {'flex-end', 'end'} & set(align_content):
setattr(
child, direction,
current_value + extra_cross_size)
elif 'center' in align_content:
setattr(
child, direction,
current_value + extra_cross_size / 2)
elif 'space-around' in align_content:
setattr(
child, direction,
current_value + extra_cross_size /
len(flex_lines) / 2)
elif 'space-evenly' in align_content:
setattr(
child, direction,
current_value + extra_cross_size /
(len(flex_lines) + 1))
if 'space-between' in align_content:
cross_translate += extra_cross_size / (len(flex_lines) - 1)
elif 'space-around' in align_content:
cross_translate += extra_cross_size / len(flex_lines)
elif 'space-evenly' in align_content:
cross_translate += extra_cross_size / (len(flex_lines) + 1)
# TODO: don't use block_box_layout, see TODOs in Step 14 and
# build.flex_children.
box = box.copy()
box.children = []
child_skip_stack = skip_stack
for line in flex_lines:
for i, child in line:
if child.is_flex_item:
new_child, child_resume_at = block.block_level_layout_switch(
context, child, bottom_space, child_skip_stack, box,
page_is_empty, absolute_boxes, fixed_boxes,
adjoining_margins=[], discard=False, max_lines=None)[:2]
if new_child is None:
if resume_at:
index, = resume_at
if index:
resume_at = {index + i - 1: None}
else:
box.children.append(new_child)
if child_resume_at is not None:
if original_skip_stack:
first_level_skip, = original_skip_stack
else:
first_level_skip = 0
if resume_at:
index, = resume_at
first_level_skip += index
resume_at = {first_level_skip + i: child_resume_at}
if resume_at:
break
# Skip stack is only for the first child
child_skip_stack = None
if resume_at:
break
# Set box height
# TODO: this is probably useless because of step #15
if axis == 'width' and box.height == 'auto':
if flex_lines:
box.height = sum(line.cross_size for line in flex_lines)
else:
box.height = 0
# Set baseline
# See https://www.w3.org/TR/css-flexbox-1/#flex-baselines
# TODO: use the real algorithm
if isinstance(box, boxes.InlineFlexBox):
if axis == 'width': # and main text direction is horizontal
box.baseline = flex_lines[0].lower_baseline if flex_lines else 0
else:
box.baseline = ((
find_in_flow_baseline(box.children[0])
if box.children else 0) or 0)
context.finish_block_formatting_context(box)
# TODO: check these returned values
return box, resume_at, {'break': 'any', 'page': None}, [], False

View File

@@ -0,0 +1,229 @@
"""Layout for floating boxes."""
from ..formatting_structure import boxes
from .min_max import handle_min_max_width
from .percent import resolve_percentages, resolve_position_percentages
from .preferred import shrink_to_fit
from .replaced import inline_replaced_box_width_height
from .table import table_wrapper_width
@handle_min_max_width
def float_width(box, context, containing_block):
# Check that box.width is auto even if the caller does it too, because
# the handle_min_max_width decorator can change the value
if box.width == 'auto':
box.width = shrink_to_fit(context, box, containing_block.width)
def float_layout(context, box, containing_block, absolute_boxes, fixed_boxes,
bottom_space, skip_stack):
"""Set the width and position of floating ``box``."""
from .block import block_container_layout
from .flex import flex_layout
from .grid import grid_layout
cb_width, cb_height = (containing_block.width, containing_block.height)
resolve_percentages(box, (cb_width, cb_height))
# TODO: This is only handled later in blocks.block_container_layout
# https://www.w3.org/TR/CSS21/visudet.html#normal-block
if cb_height == 'auto':
cb_height = (
containing_block.position_y - containing_block.content_box_y())
resolve_position_percentages(box, (cb_width, cb_height))
if box.margin_left == 'auto':
box.margin_left = 0
if box.margin_right == 'auto':
box.margin_right = 0
if box.margin_top == 'auto':
box.margin_top = 0
if box.margin_bottom == 'auto':
box.margin_bottom = 0
clearance = get_clearance(context, box)
if clearance is not None:
box.position_y += clearance
if isinstance(box, boxes.BlockReplacedBox):
inline_replaced_box_width_height(box, containing_block)
elif box.width == 'auto':
float_width(box, context, containing_block)
if box.is_table_wrapper:
table_wrapper_width(context, box, (cb_width, cb_height))
if isinstance(box, boxes.BlockContainerBox):
context.create_block_formatting_context()
box, resume_at, _, _, _, _ = block_container_layout(
context, box, bottom_space=bottom_space,
skip_stack=skip_stack, page_is_empty=True,
absolute_boxes=absolute_boxes, fixed_boxes=fixed_boxes,
adjoining_margins=None, discard=False, max_lines=None)
context.finish_block_formatting_context(box)
elif isinstance(box, boxes.FlexContainerBox):
box, resume_at, _, _, _ = flex_layout(
context, box, bottom_space=bottom_space,
skip_stack=skip_stack, containing_block=containing_block,
page_is_empty=True, absolute_boxes=absolute_boxes,
fixed_boxes=fixed_boxes)
elif isinstance(box, boxes.GridContainerBox):
box, resume_at, _, _, _ = grid_layout(
context, box, bottom_space=bottom_space,
skip_stack=skip_stack, containing_block=containing_block,
page_is_empty=True, absolute_boxes=absolute_boxes,
fixed_boxes=fixed_boxes)
else:
assert isinstance(box, boxes.BlockReplacedBox)
resume_at = None
box = find_float_position(context, box, containing_block)
context.excluded_shapes.append(box)
return box, resume_at
def find_float_position(context, box, containing_block):
"""Get the right position of the float ``box``."""
# See https://www.w3.org/TR/CSS2/visuren.html#float-position
# Point 4 is already handled as box.position_y is set according to the
# containing box top position, with collapsing margins handled
# Points 5 and 6, box.position_y is set to the highest position_y possible
if context.excluded_shapes:
highest_y = context.excluded_shapes[-1].position_y
if box.position_y < highest_y:
box.translate(0, highest_y - box.position_y)
# Points 1 and 2
position_x, position_y, available_width = avoid_collisions(
context, box, containing_block)
# Point 9
# position_y is set now, let's define position_x
# for float: left elements, it's already done!
if box.style['float'] == 'right':
position_x += available_width - box.margin_width()
box.translate(position_x - box.position_x, position_y - box.position_y)
return box
def get_clearance(context, box, collapsed_margin=0):
"""Return None if there is no clearance, otherwise the clearance value."""
clearance = None
hypothetical_position = box.position_y + collapsed_margin
# Hypothetical position is the position of the top border edge
for excluded_shape in context.excluded_shapes:
if box.style['clear'] in (excluded_shape.style['float'], 'both'):
y, h = excluded_shape.position_y, excluded_shape.margin_height()
if hypothetical_position < y + h:
clearance = max(
(clearance or 0), y + h - hypothetical_position)
return clearance
def avoid_collisions(context, box, containing_block, outer=True):
excluded_shapes = context.excluded_shapes
position_y = box.position_y if outer else box.border_box_y()
box_width = box.margin_width() if outer else box.border_width()
box_height = box.margin_height() if outer else box.border_height()
if box.border_height() == 0 and box.is_floated():
return 0, 0, containing_block.width
while True:
colliding_shapes = []
for shape in excluded_shapes:
# Assign locals to avoid slow attribute lookups.
shape_position_y = shape.position_y
shape_margin_height = shape.margin_height()
if ((shape_position_y < position_y <
shape_position_y + shape_margin_height) or
(shape_position_y < position_y + box_height <
shape_position_y + shape_margin_height) or
(shape_position_y >= position_y and
shape_position_y + shape_margin_height <=
position_y + box_height)):
colliding_shapes.append(shape)
left_bounds = [
shape.position_x + shape.margin_width()
for shape in colliding_shapes
if shape.style['float'] == 'left']
right_bounds = [
shape.position_x
for shape in colliding_shapes
if shape.style['float'] == 'right']
# Set the default maximum bounds
max_left_bound = containing_block.content_box_x()
max_right_bound = \
containing_block.content_box_x() + containing_block.width
if not outer:
max_left_bound += box.margin_left
max_right_bound -= box.margin_right
# Set the real maximum bounds according to sibling float elements
if left_bounds or right_bounds:
if left_bounds:
max_left_bound = max(max(left_bounds), max_left_bound)
if right_bounds:
max_right_bound = min(min(right_bounds), max_right_bound)
# Points 3, 7 and 8
if box_width > max_right_bound - max_left_bound:
# The box does not fit here
new_positon_y = min(
shape.position_y + shape.margin_height()
for shape in colliding_shapes)
if new_positon_y > position_y:
# We can find a solution with a higher position_y
position_y = new_positon_y
continue
# No solution, we must put the box here
break
# See https://www.w3.org/TR/CSS21/visuren.html#floats
# Boxes that cant collide with floats are:
# - floats
# - line boxes
# - table wrappers
# - block-level replaced box
# - element establishing new formatting contexts (not handled)
assert (
(box.style['float'] in ('right', 'left')) or
isinstance(box, boxes.LineBox) or
box.is_table_wrapper or
isinstance(box, boxes.BlockReplacedBox))
# The x-position of the box depends on its type.
position_x = max_left_bound
if box.style['float'] == 'none':
if containing_block.style['direction'] == 'rtl':
if isinstance(box, boxes.LineBox):
# The position of the line is the position of the cursor, at
# the right bound.
position_x = max_right_bound
elif box.is_table_wrapper:
# The position of the right border of the table is at the right
# bound.
position_x = max_right_bound - box_width
else:
# The position of the right border of the replaced box is at
# the right bound.
assert isinstance(box, boxes.BlockReplacedBox)
position_x = max_right_bound - box_width
available_width = max_right_bound - max_left_bound
if not outer:
position_x -= box.margin_left
position_y -= box.margin_top
return position_x, position_y, available_width

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,75 @@
"""Leaders management."""
from ..formatting_structure import boxes
def leader_index(box):
"""Get the index of the first leader box in ``box``."""
for i, child in enumerate(box.children):
if child.is_leader:
return (i, None), child
if isinstance(child, boxes.ParentBox):
child_leader_index, child_leader = leader_index(child)
if child_leader_index is not None:
return (i, child_leader_index), child_leader
return None, None
def handle_leader(context, line, containing_block):
"""Find a leader box in ``line`` and handle its text and its position."""
index, leader_box = leader_index(line)
extra_width = 0
if index is not None and leader_box.children:
text_box, = leader_box.children
# Abort if the leader text has no width
if text_box.width <= 0:
return
# Extra width is the additional width taken by the leader box
extra_width = containing_block.width - sum(
child.margin_width() for child in line.children
if child.is_in_normal_flow())
# Take care of excluded shapes
for shape in context.excluded_shapes:
if shape.position_y + shape.height > line.position_y:
extra_width -= shape.width
# Available width is the width available for the leader box
available_width = extra_width + text_box.width
line.width = containing_block.width
# Add text boxes into the leader box
number_of_leaders = int(line.width // text_box.width)
position_x = line.position_x + line.width
children = []
for i in range(number_of_leaders):
position_x -= text_box.width
if position_x < leader_box.position_x:
# Dont add leaders behind the text on the left
continue
elif (position_x + text_box.width >
leader_box.position_x + available_width):
# Dont add leaders behind the text on the right
continue
text_box = text_box.copy()
text_box.position_x = position_x
children.append(text_box)
leader_box.children = tuple(children)
if line.style['direction'] == 'rtl':
leader_box.translate(dx=-extra_width)
# Widen leader parent boxes and translate following boxes
box = line
while index is not None:
for child in box.children[index[0] + 1:]:
if child.is_in_normal_flow():
if line.style['direction'] == 'ltr':
child.translate(dx=extra_width)
else:
child.translate(dx=-extra_width)
box = box.children[index[0]]
box.width += extra_width
index = index[1]

View File

@@ -0,0 +1,41 @@
"""Decorators handling min- and max- widths and heights."""
import functools
def handle_min_max_width(function):
"""Decorate a function setting used width, handling {min,max}-width."""
@functools.wraps(function)
def wrapper(box, *args):
computed_margins = box.margin_left, box.margin_right
result = function(box, *args)
if box.width > box.max_width:
box.width = box.max_width
box.margin_left, box.margin_right = computed_margins
result = function(box, *args)
if box.width < box.min_width:
box.width = box.min_width
box.margin_left, box.margin_right = computed_margins
result = function(box, *args)
return result
wrapper.without_min_max = function
return wrapper
def handle_min_max_height(function):
"""Decorate a function setting used height, handling {min,max}-height."""
@functools.wraps(function)
def wrapper(box, *args):
computed_margins = box.margin_top, box.margin_bottom
result = function(box, *args)
if box.height > box.max_height:
box.height = box.max_height
box.margin_top, box.margin_bottom = computed_margins
result = function(box, *args)
if box.height < box.min_height:
box.height = box.min_height
box.margin_top, box.margin_bottom = computed_margins
result = function(box, *args)
return result
wrapper.without_min_max = function
return wrapper

View File

@@ -0,0 +1,922 @@
"""Layout for pages and CSS3 margin boxes."""
import copy
from math import inf
from ..css import PageType, computed_from_cascaded
from ..formatting_structure import boxes, build
from ..logger import PROGRESS_LOGGER
from .absolute import absolute_box_layout, absolute_layout
from .block import block_container_layout, block_level_layout
from .float import float_layout
from .min_max import handle_min_max_height, handle_min_max_width
from .percent import resolve_percentages
from .preferred import max_content_width, min_content_width
class OrientedBox:
@property
def sugar(self):
return self.padding_plus_border + self.margin_a + self.margin_b
@property
def outer(self):
return self.sugar + self.inner
@outer.setter
def outer(self, new_outer_width):
self.inner = min(
max(self.min_content_size, new_outer_width - self.sugar),
self.max_content_size)
@property
def outer_min_content_size(self):
return self.sugar + (
self.min_content_size if self.inner == 'auto' else self.inner)
@property
def outer_max_content_size(self):
return self.sugar + (
self.max_content_size if self.inner == 'auto' else self.inner)
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: Define what are the min-content and max-content heights
@property
def min_content_size(self):
return 0
@property
def max_content_size(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._min_content_size = None
self._max_content_size = 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 min_content_size(self):
if self._min_content_size is None:
self._min_content_size = min_content_width(
self.context, self.box, outer=False)
return self._min_content_size
@property
def max_content_size(self):
if self._max_content_size is None:
self._max_content_size = max_content_width(
self.context, self.box, outer=False)
return self._max_content_size
def compute_fixed_dimension(context, box, outer, vertical, top_or_left):
"""Compute and set a margin box fixed dimension on ``box``.
Described in: https://drafts.csswg.org/css-page-3/#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.
# https://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 == 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]
box.restore_box_attributes()
def compute_variable_dimension(context, side_boxes, vertical, available_size):
"""Compute and set a margin box fixed dimension on ``box``
Described in: https://drafts.csswg.org/css-page-3/#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 available_size:
The distance between the page boxs left right border edges
"""
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 not box_b.box.is_generated:
# Non-generated boxes get zero for every box-model property
assert box_b.inner == 0
if box_a.inner == box_c.inner == 'auto':
# A and C both have 'width: auto'
if available_size > (
box_a.outer_max_content_size +
box_c.outer_max_content_size):
# sum of the outer max-content widths
# is less than the available width
flex_space = (
available_size -
box_a.outer_max_content_size -
box_c.outer_max_content_size)
flex_factor_a = box_a.outer_max_content_size
flex_factor_c = box_c.outer_max_content_size
flex_factor_sum = flex_factor_a + flex_factor_c
if flex_factor_sum == 0:
flex_factor_sum = 1
box_a.outer = box_a.max_content_size + (
flex_space * flex_factor_a / flex_factor_sum)
box_c.outer = box_c.max_content_size + (
flex_space * flex_factor_c / flex_factor_sum)
elif available_size > (
box_a.outer_min_content_size +
box_c.outer_min_content_size):
# sum of the outer min-content widths
# is less than the available width
flex_space = (
available_size -
box_a.outer_min_content_size -
box_c.outer_min_content_size)
flex_factor_a = (
box_a.max_content_size - box_a.min_content_size)
flex_factor_c = (
box_c.max_content_size - box_c.min_content_size)
flex_factor_sum = flex_factor_a + flex_factor_c
if flex_factor_sum == 0:
flex_factor_sum = 1
box_a.outer = box_a.min_content_size + (
flex_space * flex_factor_a / flex_factor_sum)
box_c.outer = box_c.min_content_size + (
flex_space * flex_factor_c / flex_factor_sum)
else:
# otherwise
flex_space = (
available_size -
box_a.outer_min_content_size -
box_c.outer_min_content_size)
flex_factor_a = box_a.min_content_size
flex_factor_c = box_c.min_content_size
flex_factor_sum = flex_factor_a + flex_factor_c
if flex_factor_sum == 0:
flex_factor_sum = 1
box_a.outer = box_a.min_content_size + (
flex_space * flex_factor_a / flex_factor_sum)
box_c.outer = box_c.min_content_size + (
flex_space * flex_factor_c / flex_factor_sum)
else:
# only one box has 'width: auto'
if box_a.inner == 'auto':
box_a.outer = available_size - box_c.outer
elif box_c.inner == 'auto':
box_c.outer = available_size - box_a.outer
else:
if box_b.inner == 'auto':
# resolve any auto width of the middle box (B)
ac_max_content_size = 2 * max(
box_a.outer_max_content_size, box_c.outer_max_content_size)
if available_size > (
box_b.outer_max_content_size + ac_max_content_size):
flex_space = (
available_size -
box_b.outer_max_content_size -
ac_max_content_size)
flex_factor_b = box_b.outer_max_content_size
flex_factor_ac = ac_max_content_size
flex_factor_sum = flex_factor_b + flex_factor_ac
if flex_factor_sum == 0:
flex_factor_sum = 1
box_b.outer = box_b.max_content_size + (
flex_space * flex_factor_b / flex_factor_sum)
else:
ac_min_content_size = 2 * max(
box_a.outer_min_content_size, box_c.outer_min_content_size)
if available_size > (
box_b.outer_min_content_size + ac_min_content_size):
flex_space = (
available_size -
box_b.outer_min_content_size -
ac_min_content_size)
flex_factor_b = (
box_b.max_content_size - box_b.min_content_size)
flex_factor_ac = ac_max_content_size - ac_min_content_size
flex_factor_sum = flex_factor_b + flex_factor_ac
if flex_factor_sum == 0:
flex_factor_sum = 1
box_b.outer = box_b.min_content_size + (
flex_space * flex_factor_b / flex_factor_sum)
else:
flex_space = (
available_size -
box_b.outer_min_content_size -
ac_min_content_size)
flex_factor_b = box_b.min_content_size
flex_factor_ac = ac_min_content_size
flex_factor_sum = flex_factor_b + flex_factor_ac
if flex_factor_sum == 0:
flex_factor_sum = 1
box_b.outer = box_b.min_content_size + (
flex_space * flex_factor_b / flex_factor_sum)
if box_a.inner == 'auto':
box_a.outer = (available_size - box_b.outer) / 2
if box_c.inner == 'auto':
box_c.outer = (available_size - box_b.outer) / 2
# And, were 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 _standardize_page_based_counters(style, pseudo_type):
"""Drop 'pages' counter from style in @page and @margin context.
Ensure `counter-increment: page` for @page context if not otherwise
manipulated by the style.
"""
page_counter_touched = False
for propname in ('counter_set', 'counter_reset', 'counter_increment'):
if style[propname] == 'auto':
style[propname] = ()
continue
justified_values = []
for name, value in style[propname]:
if name == 'page':
page_counter_touched = True
if name != 'pages':
justified_values.append((name, value))
style[propname] = tuple(justified_values)
if pseudo_type is None and not page_counter_touched:
style['counter_increment'] = (
('page', 1),) + style['counter_increment']
def make_margin_boxes(context, page, state):
"""Yield laid-out margin boxes for this page.
``state`` is the actual, up-to-date page-state from
``context.page_maker[context.current_page]``.
"""
# This is a closure only to make calls shorter
def make_box(at_keyword, containing_block):
"""Return a margin box with resolved percentages.
The margin box may still have 'auto' values.
Return ``None`` if this margin box should not be generated.
:param at_keyword:
Which margin box to return, e.g. '@top-left'
:param containing_block:
As expected by :func:`resolve_percentages`.
"""
style = context.style_for(page.page_type, at_keyword)
if style is None:
# doesn't affect counters
style = computed_from_cascaded(
element=None, cascaded={}, parent_style=page.style)
_standardize_page_based_counters(style, at_keyword)
box = boxes.MarginBox(at_keyword, style)
# Empty boxes should not be generated, but they may be needed for
# the layout of their neighbors.
# TODO: should be the computed value.
box.is_generated = style['content'] not in (
'normal', 'inhibit', 'none')
# TODO: get actual counter values at the time of the last page break
if box.is_generated:
# @margins mustn't manipulate page-context counters
margin_state = copy.deepcopy(state)
quote_depth, counter_values, counter_scopes = margin_state
# TODO: check this, probably useless
counter_scopes.append(set())
build.update_counters(margin_state, box.style)
box.children = build.content_to_boxes(
box.style, box, quote_depth, counter_values,
context.get_image_from_uri, context.target_collector,
context.counter_style, context, page)
build.process_whitespace(box)
build.process_text_transform(box)
box = build.create_anonymous_boxes(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
# https://drafts.csswg.org/css-page-3/#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(f'@{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 boxs content once the box has dimensions."""
positioned_boxes = []
box, resume_at, next_page, _, _, _ = block_container_layout(
context, box, bottom_space=-inf, skip_stack=None, page_is_empty=True,
absolute_boxes=positioned_boxes, fixed_boxes=positioned_boxes,
adjoining_margins=None, discard=False, max_lines=None)
assert resume_at is None
for absolute_box in positioned_boxes:
absolute_layout(
context, absolute_box, box, positioned_boxes, bottom_space=0,
skip_stack=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."
https://drafts.csswg.org/css-page-3/#page-box-page-rule
https://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, page_number,
page_state):
"""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 int page_number:
Page number, starts 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 <body>.
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()
context.page_bottom = root_box.position_y + page.height
initial_containing_block = page
footnote_area_style = context.style_for(page_type, '@footnote')
footnote_area = boxes.FootnoteAreaBox(page, footnote_area_style)
resolve_percentages(footnote_area, page)
footnote_area.position_x = page.content_box_x()
footnote_area.position_y = context.page_bottom
if page_type.blank:
previous_resume_at = resume_at
root_box = root_box.copy_with_children([])
# TODO: handle cases where the root element is something else.
# See https://www.w3.org/TR/CSS21/visuren.html#dis-pos-flo
assert isinstance(root_box, (
boxes.BlockBox, boxes.FlexContainerBox, boxes.GridContainerBox))
context.create_block_formatting_context()
context.current_page = page_number
context.current_page_footnotes = []
context.current_footnote_area = footnote_area
reported_footnotes = context.reported_footnotes
context.reported_footnotes = []
for i, reported_footnote in enumerate(reported_footnotes):
context.footnotes.append(reported_footnote)
overflow = context.layout_footnote(reported_footnote)
if overflow and i != 0:
context.report_footnote(reported_footnote)
context.reported_footnotes = reported_footnotes[i:]
break
page_is_empty = True
adjoining_margins = []
positioned_boxes = [] # Mixed absolute and fixed
out_of_flow_boxes = []
broken_out_of_flow = {}
context_out_of_flow = context.broken_out_of_flow.values()
context.broken_out_of_flow = broken_out_of_flow
for box, containing_block, skip_stack in context_out_of_flow:
box.position_y = root_box.content_box_y()
if box.is_floated():
out_of_flow_box, out_of_flow_resume_at = float_layout(
context, box, containing_block, positioned_boxes,
positioned_boxes, 0, skip_stack)
else:
assert box.is_absolutely_positioned()
out_of_flow_box, out_of_flow_resume_at = absolute_box_layout(
context, box, containing_block, positioned_boxes, 0,
skip_stack)
out_of_flow_boxes.append(out_of_flow_box)
if out_of_flow_resume_at:
broken_out_of_flow[out_of_flow_box] = (
box, containing_block, out_of_flow_resume_at)
root_box, resume_at, next_page, _, _, _ = block_level_layout(
context, root_box, 0, resume_at, initial_containing_block,
page_is_empty, positioned_boxes, positioned_boxes, adjoining_margins)
assert root_box
root_box.children = out_of_flow_boxes + root_box.children
footnote_area = build.create_anonymous_boxes(footnote_area.deepcopy())
footnote_area = block_level_layout(
context, footnote_area, -inf, None, footnote_area.page, True,
positioned_boxes, positioned_boxes)[0]
footnote_area.translate(dy=-footnote_area.margin_height())
page.fixed_boxes = [
placeholder._box for placeholder in positioned_boxes
if placeholder._box.style['position'] == 'fixed']
for absolute_box in positioned_boxes:
absolute_layout(
context, absolute_box, page, positioned_boxes, bottom_space=0,
skip_stack=None)
context.finish_block_formatting_context(root_box)
page.children = [root_box, footnote_area]
# Update page counter values
_standardize_page_based_counters(style, None)
build.update_counters(page_state, style)
page_counter_values = page_state[1]
# page_counter_values will be cached in the page_maker
target_collector = context.target_collector
page_maker = context.page_maker
# remake_state tells the make_all_pages-loop in layout_document()
# whether and what to re-make.
remake_state = page_maker[page_number - 1][-1]
# Evaluate and cache page values only once (for the first LineBox)
# otherwise we suffer endless loops when the target/pseudo-element
# spans across multiple pages
cached_anchors = []
cached_lookups = []
for (_, _, _, _, x_remake_state) in page_maker[:page_number - 1]:
cached_anchors.extend(x_remake_state.get('anchors', []))
cached_lookups.extend(x_remake_state.get('content_lookups', []))
for child in page.descendants(placeholders=True):
# Cache target's page counters
anchor = child.style['anchor']
if anchor and anchor not in cached_anchors:
remake_state['anchors'].append(anchor)
cached_anchors.append(anchor)
# Re-make of affected targeting boxes is inclusive
target_collector.cache_target_page_counters(
anchor, page_counter_values, page_number - 1, page_maker)
# string-set and bookmark-labels don't create boxes, only `content`
# requires another call to make_page. There is maximum one 'content'
# item per box.
if child.missing_link:
# A CounterLookupItem exists for the css-token 'content'
counter_lookup = target_collector.counter_lookup_items.get(
(child.missing_link, 'content'))
else:
counter_lookup = None
# Resolve missing (page based) counters
if counter_lookup is not None:
call_parse_again = False
# Prevent endless loops
counter_lookup_id = id(counter_lookup)
refresh_missing_counters = counter_lookup_id not in cached_lookups
if refresh_missing_counters:
remake_state['content_lookups'].append(counter_lookup_id)
cached_lookups.append(counter_lookup_id)
counter_lookup.page_maker_index = page_number - 1
# Step 1: page based back-references
# Marked as pending by target_collector.cache_target_page_counters
if counter_lookup.pending:
if (page_counter_values !=
counter_lookup.cached_page_counter_values):
counter_lookup.cached_page_counter_values = copy.deepcopy(
page_counter_values)
counter_lookup.pending = False
call_parse_again = True
# Step 2: local counters
# If the box mixed-in page counters changed, update the content
# and cache the new values.
missing_counters = counter_lookup.missing_counters
if missing_counters:
if 'pages' in missing_counters:
remake_state['pages_wanted'] = True
if refresh_missing_counters and page_counter_values != \
counter_lookup.cached_page_counter_values:
counter_lookup.cached_page_counter_values = \
copy.deepcopy(page_counter_values)
for counter_name in missing_counters:
counter_value = page_counter_values.get(
counter_name, None)
if counter_value is not None:
call_parse_again = True
# no need to loop them all
break
# Step 3: targeted counters
target_missing = counter_lookup.missing_target_counters
for anchor_name, missed_counters in target_missing.items():
if 'pages' not in missed_counters:
continue
# Adjust 'pages_wanted'
item = target_collector.target_lookup_items.get(
anchor_name, None)
page_maker_index = item.page_maker_index
if page_maker_index >= 0 and anchor_name in cached_anchors:
page_maker[page_maker_index][-1]['pages_wanted'] = True
# 'content_changed' is triggered in
# targets.cache_target_page_counters()
if call_parse_again:
remake_state['content_changed'] = True
counter_lookup.parse_again(page_counter_values)
if page_type.blank:
resume_at = previous_resume_at
next_page = page_maker[page_number - 1][1]
return page, resume_at, next_page
def set_page_type_computed_styles(page_type, html, style_for):
"""Set style for page types and pseudo-types matching ``page_type``."""
style_for.add_page_declarations(page_type)
# Apply style for page
style_for.set_computed_styles(
page_type,
# @page inherits from the root element:
# https://lists.w3.org/Archives/Public/www-style/2012Jan/1164.html
root=html.etree_element, parent=html.etree_element,
base_url=html.base_url)
# Apply style for page pseudo-elements (margin boxes)
for element, pseudo_type in style_for.get_cascaded_styles():
if pseudo_type and element == page_type:
style_for.set_computed_styles(
element, pseudo_type=pseudo_type,
# The pseudo-element inherits from the element.
root=html.etree_element, parent=element,
base_url=html.base_url)
def remake_page(index, context, root_box, html):
"""Return one laid out page without margin boxes.
Start with the initial values from ``context.page_maker[index]``.
The resulting values / initial values for the next page are stored in
the ``page_maker``.
As the function's name suggests: the plan is not to make all pages
repeatedly when a missing counter was resolved, but rather re-make the
single page where the ``content_changed`` happened.
"""
page_maker = context.page_maker
(initial_resume_at, initial_next_page, right_page, initial_page_state,
remake_state) = page_maker[index]
# PageType for current page, values for page_maker[index + 1].
# Don't modify actual page_maker[index] values!
# TODO: should we store (and reuse) page_type in the page_maker?
page_state = copy.deepcopy(initial_page_state)
first = index == 0
if initial_next_page['break'] in ('left', 'right'):
next_page_side = initial_next_page['break']
elif initial_next_page['break'] in ('recto', 'verso'):
direction_ltr = root_box.style['direction'] == 'ltr'
break_verso = initial_next_page['break'] == 'verso'
next_page_side = 'right' if direction_ltr ^ break_verso else 'left'
else:
next_page_side = None
blank = bool(
(next_page_side == 'left' and right_page) or
(next_page_side == 'right' and not right_page) or
(context.reported_footnotes and initial_resume_at is None))
next_page_name = '' if blank else initial_next_page['page']
side = 'right' if right_page else 'left'
page_type = PageType(side, blank, first, index, name=next_page_name)
set_page_type_computed_styles(page_type, html, context.style_for)
context.forced_break = (
initial_next_page['break'] != 'any' or initial_next_page['page'])
context.margin_clearance = False
# make_page wants a page_number of index + 1
page_number = index + 1
page, resume_at, next_page = make_page(
context, root_box, page_type, initial_resume_at, page_number,
page_state)
assert next_page
right_page = not right_page
# Check whether we need to append or update the next page_maker item
if index + 1 >= len(page_maker):
# New page
page_maker_next_changed = True
else:
# Check whether something changed
# TODO: Find what we need to compare. Is resume_at enough?
(next_resume_at, next_next_page, next_right_page,
next_page_state, _) = page_maker[index + 1]
page_maker_next_changed = (
next_resume_at != resume_at or
next_next_page != next_page or
next_right_page != right_page or
next_page_state != page_state)
if page_maker_next_changed:
# Reset remake_state
remake_state = {
'content_changed': False,
'pages_wanted': False,
'anchors': [],
'content_lookups': [],
}
# Setting content_changed to True ensures remake.
# If resume_at is None (last page) it must be False to prevent endless
# loops and list index out of range (see #794).
remake_state['content_changed'] = resume_at is not None
# page_state is already a deepcopy
item = resume_at, next_page, right_page, page_state, remake_state
if index + 1 >= len(page_maker):
page_maker.append(item)
else:
page_maker[index + 1] = item
return page, resume_at
def make_all_pages(context, root_box, html, pages):
"""Return a list of laid out pages without margin boxes.
Re-make pages only if necessary.
"""
i = 0
reported_footnotes = None
while True:
remake_state = context.page_maker[i][-1]
if (len(pages) == 0 or
remake_state['content_changed'] or
remake_state['pages_wanted']):
PROGRESS_LOGGER.info('Step 5 - Creating layout - Page %d', i + 1)
# Reset remake_state
remake_state['content_changed'] = False
remake_state['pages_wanted'] = False
remake_state['anchors'] = []
remake_state['content_lookups'] = []
page, resume_at = remake_page(i, context, root_box, html)
reported_footnotes = context.reported_footnotes
yield page
else:
PROGRESS_LOGGER.info(
'Step 5 - Creating layout - Page %d (up-to-date)', i + 1)
resume_at = context.page_maker[i + 1][0]
reported_footnotes = None
yield pages[i]
i += 1
if resume_at is None and not reported_footnotes:
# Throw away obsolete pages and content
context.page_maker = context.page_maker[:i + 1]
context.broken_out_of_flow.clear()
context.reported_footnotes.clear()
return

View File

@@ -0,0 +1,156 @@
"""Resolve percentages into fixed values."""
from math import inf
from ..formatting_structure import boxes
def percentage(value, refer_to):
"""Return the percentage of the reference value, or the value unchanged.
``refer_to`` is the length for 100%. If ``refer_to`` is not a number, it
just replaces percentages.
"""
if value is None or value == 'auto':
return value
elif value.unit == 'px':
return value.value
else:
assert value.unit == '%'
return refer_to * value.value / 100
def resolve_one_percentage(box, property_name, refer_to,
main_flex_direction=None):
"""Set a used length value from a computed length value.
``refer_to`` is the length for 100%. If ``refer_to`` is not a number, it
just replaces percentages.
"""
# box.style has computed values
value = box.style[property_name]
# box attributes are used values
percent = percentage(value, refer_to)
setattr(box, property_name, percent)
if property_name in ('min_width', 'min_height') and percent == 'auto':
if (main_flex_direction is None or
property_name != (f'min_{main_flex_direction}')):
setattr(box, property_name, 0)
def resolve_position_percentages(box, containing_block):
cb_width, cb_height = containing_block
resolve_one_percentage(box, 'left', cb_width)
resolve_one_percentage(box, 'right', cb_width)
resolve_one_percentage(box, 'top', cb_height)
resolve_one_percentage(box, 'bottom', cb_height)
def resolve_percentages(box, containing_block, main_flex_direction=None):
"""Set used values as attributes of the box object."""
if isinstance(containing_block, boxes.Box):
# cb is short for containing block
cb_width = containing_block.width
cb_height = containing_block.height
else:
cb_width, cb_height = containing_block
if isinstance(box, boxes.PageBox):
maybe_height = cb_height
else:
maybe_height = cb_width
resolve_one_percentage(box, 'margin_left', cb_width)
resolve_one_percentage(box, 'margin_right', cb_width)
resolve_one_percentage(box, 'margin_top', maybe_height)
resolve_one_percentage(box, 'margin_bottom', maybe_height)
resolve_one_percentage(box, 'padding_left', cb_width)
resolve_one_percentage(box, 'padding_right', cb_width)
resolve_one_percentage(box, 'padding_top', maybe_height)
resolve_one_percentage(box, 'padding_bottom', maybe_height)
resolve_one_percentage(box, 'width', cb_width)
resolve_one_percentage(box, 'min_width', cb_width, main_flex_direction)
resolve_one_percentage(box, 'max_width', cb_width, main_flex_direction)
# XXX later: top, bottom, left and right on positioned elements
if cb_height == 'auto':
# Special handling when the height of the containing block
# depends on its content.
height = box.style['height']
if height == 'auto' or height.unit == '%':
box.height = 'auto'
else:
assert height.unit == 'px'
box.height = height.value
resolve_one_percentage(box, 'min_height', 0, main_flex_direction)
resolve_one_percentage(box, 'max_height', inf, main_flex_direction)
else:
resolve_one_percentage(box, 'height', cb_height)
resolve_one_percentage(
box, 'min_height', cb_height, main_flex_direction)
resolve_one_percentage(
box, 'max_height', cb_height, main_flex_direction)
collapse = box.style['border_collapse'] == 'collapse'
# Used value == computed value
for side in ('top', 'right', 'bottom', 'left'):
prop = f'border_{side}_width'
# border-{side}-width would have been resolved
# during border conflict resolution for collapsed-borders
if not (collapse and hasattr(box, prop)):
setattr(box, prop, box.style[prop])
# Shrink *content* widths and heights according to box-sizing
# Thanks heavens and the spec: Our validator rejects negative values
# for padding and border-width
if box.style['box_sizing'] == 'border-box':
horizontal_delta = (
box.padding_left + box.padding_right +
box.border_left_width + box.border_right_width)
vertical_delta = (
box.padding_top + box.padding_bottom +
box.border_top_width + box.border_bottom_width)
elif box.style['box_sizing'] == 'padding-box':
horizontal_delta = box.padding_left + box.padding_right
vertical_delta = box.padding_top + box.padding_bottom
else:
assert box.style['box_sizing'] == 'content-box'
horizontal_delta = 0
vertical_delta = 0
# Keep at least min_* >= 0 to prevent funny output in case box.width or
# box.height become negative.
# Restricting max_* seems reasonable, too.
if horizontal_delta > 0:
if box.width != 'auto':
box.width = max(0, box.width - horizontal_delta)
box.max_width = max(0, box.max_width - horizontal_delta)
if box.min_width != 'auto':
box.min_width = max(0, box.min_width - horizontal_delta)
if vertical_delta > 0:
if box.height != 'auto':
box.height = max(0, box.height - vertical_delta)
box.max_height = max(0, box.max_height - vertical_delta)
if box.min_height != 'auto':
box.min_height = max(0, box.min_height - vertical_delta)
def resolve_radii_percentages(box):
for corner in ('top_left', 'top_right', 'bottom_right', 'bottom_left'):
property_name = f'border_{corner}_radius'
rx, ry = box.style[property_name]
# Short track for common case
if (0, 'px') in (rx, ry):
setattr(box, property_name, (0, 0))
continue
for side in corner.split('_'):
if side in box.remove_decoration_sides:
setattr(box, property_name, (0, 0))
break
else:
rx = percentage(rx, box.border_width())
ry = percentage(ry, box.border_height())
setattr(box, property_name, (rx, ry))

View File

@@ -0,0 +1,779 @@
"""Preferred and minimum preferred width.
Also known as max-content and min-content width, also known as the
shrink-to-fit algorithm.
Terms used (max-content width, min-content width) are defined in David
Baron's unofficial draft (https://dbaron.org/css/intrinsic/).
"""
import sys
from math import inf
from ..formatting_structure import boxes
from ..text.line_break import split_first_line
from .replaced import default_image_sizing
def shrink_to_fit(context, box, available_content_width):
"""Return the shrink-to-fit width of ``box``.
*Warning:* both available_content_width and the return value are
for width of the *content area*, not margin area.
https://www.w3.org/TR/CSS21/visudet.html#float-width
"""
return min(
max(
min_content_width(context, box, outer=False),
available_content_width),
max_content_width(context, box, outer=False))
def min_content_width(context, box, outer=True):
"""Return the min-content width for ``box``.
This is the width by breaking at every line-break opportunity.
"""
if box.is_table_wrapper:
return table_and_columns_preferred_widths(context, box, outer)[0]
elif isinstance(box, boxes.TableCellBox):
return table_cell_min_content_width(context, box, outer)
elif isinstance(box, (
boxes.BlockContainerBox, boxes.TableColumnBox, boxes.FlexBox)):
return block_min_content_width(context, box, outer)
elif isinstance(box, boxes.TableColumnGroupBox):
return column_group_content_width(context, box)
elif isinstance(box, (boxes.InlineBox, boxes.LineBox)):
return inline_min_content_width(
context, box, outer, is_line_start=True)
elif isinstance(box, boxes.ReplacedBox):
return replaced_min_content_width(box, outer)
elif isinstance(box, boxes.FlexContainerBox):
return flex_min_content_width(context, box, outer)
elif isinstance(box, boxes.GridContainerBox):
# TODO: Get real grid size.
return block_min_content_width(context, box, outer)
else:
raise TypeError(
f'min-content width for {type(box).__name__} not handled yet')
def max_content_width(context, box, outer=True):
"""Return the max-content width for ``box``.
This is the width by only breaking at forced line breaks.
"""
if box.is_table_wrapper:
return table_and_columns_preferred_widths(context, box, outer)[1]
elif isinstance(box, boxes.TableCellBox):
return table_cell_max_content_width(context, box, outer)
elif isinstance(box, (
boxes.BlockContainerBox, boxes.TableColumnBox, boxes.FlexBox)):
return block_max_content_width(context, box, outer)
elif isinstance(box, boxes.TableColumnGroupBox):
return column_group_content_width(context, box)
elif isinstance(box, (boxes.InlineBox, boxes.LineBox)):
return inline_max_content_width(
context, box, outer, is_line_start=True)
elif isinstance(box, boxes.ReplacedBox):
return replaced_max_content_width(box, outer)
elif isinstance(box, boxes.FlexContainerBox):
return flex_max_content_width(context, box, outer)
elif isinstance(box, boxes.GridContainerBox):
# TODO: Get real grid size.
return block_max_content_width(context, box, outer)
else:
raise TypeError(
f'max-content width for {type(box).__name__} not handled yet')
def _block_content_width(context, box, function, outer):
"""Helper to create ``block_*_content_width.``"""
width = box.style['width']
if width == 'auto' or width.unit == '%':
# "percentages on the following properties are treated instead as
# though they were the following: width: auto"
# https://dbaron.org/css/intrinsic/#outer-intrinsic
children_widths = [
function(context, child, outer=True) for child in box.children
if not child.is_absolutely_positioned()]
width = max(children_widths) if children_widths else 0
else:
assert width.unit == 'px'
width = width.value
return adjust(box, outer, width)
def min_max(box, width):
"""Get box width from given width and box min- and max-widths."""
min_width = box.style['min_width']
max_width = box.style['max_width']
if min_width == 'auto' or min_width.unit == '%':
min_width = 0
else:
min_width = min_width.value
if max_width == 'auto' or max_width.unit == '%':
max_width = inf
else:
max_width = max_width.value
if isinstance(box, boxes.ReplacedBox):
_, _, ratio = box.replacement.get_intrinsic_size(
1, box.style['font_size'])
if ratio is not None:
min_height = box.style['min_height']
if min_height != 'auto' and min_height.unit != '%':
min_width = max(min_width, min_height.value * ratio)
max_height = box.style['max_height']
if max_height != 'auto' and max_height.unit != '%':
max_width = min(max_width, max_height.value * ratio)
return max(min_width, min(width, max_width))
def margin_width(box, width, left=True, right=True):
"""Add box paddings, borders and margins to ``width``."""
percentages = 0
# See https://drafts.csswg.org/css-tables-3/#cell-intrinsic-offsets
# It is a set of computed values for border-left-width, padding-left,
# padding-right, and border-right-width (along with zero values for
# margin-left and margin-right)
for value in (
(['margin_left', 'padding_left'] if left else []) +
(['margin_right', 'padding_right'] if right else [])
):
style_value = box.style[value]
if style_value != 'auto':
if style_value.unit == 'px':
width += style_value.value
else:
assert style_value.unit == '%'
percentages += style_value.value
collapse = box.style['border_collapse'] == 'collapse'
if left:
if collapse and hasattr(box, 'border_left_width'):
# In collapsed-borders mode: the computed horizontal padding of the
# cell and, for border values, the used border-width values of the
# cell (half the winning border-width)
width += box.border_left_width
else:
# In separated-borders mode: the computed horizontal padding and
# border of the table-cell
width += box.style['border_left_width']
if right:
if collapse and hasattr(box, 'border_right_width'):
# [...] the used border-width values of the cell
width += box.border_right_width
else:
# [...] the computed border of the table-cell
width += box.style['border_right_width']
if percentages < 100:
return width / (1 - percentages / 100)
else:
# Pathological case, ignore
return 0
def adjust(box, outer, width, left=True, right=True):
"""Respect min/max and adjust width depending on ``outer``.
If ``outer`` is set to ``True``, return margin width, else return content
width.
"""
fixed = min_max(box, width)
if outer:
return margin_width(box, fixed, left, right)
else:
return fixed
def block_min_content_width(context, box, outer=True):
"""Return the min-content width for a ``BlockBox``."""
return _block_content_width(
context, box, min_content_width, outer)
def block_max_content_width(context, box, outer=True):
"""Return the max-content width for a ``BlockBox``."""
return _block_content_width(context, box, max_content_width, outer)
def inline_min_content_width(context, box, outer=True, skip_stack=None,
first_line=False, is_line_start=False):
"""Return the min-content width for an ``InlineBox``.
The width is calculated from the lines from ``skip_stack``. If
``first_line`` is ``True``, only the first line minimum width is
calculated.
"""
widths = inline_line_widths(
context, box, outer, is_line_start, minimum=True,
skip_stack=skip_stack, first_line=first_line)
width = next(widths) if first_line else max(widths)
return adjust(box, outer, width)
def inline_max_content_width(context, box, outer=True, is_line_start=False):
"""Return the max-content width for an ``InlineBox``."""
widths = list(
inline_line_widths(context, box, outer, is_line_start, minimum=False))
# Remove trailing space, as split_first_line keeps trailing spaces when
# max_width is not set.
widths[-1] -= trailing_whitespace_size(context, box)
return adjust(box, outer, max(widths))
def column_group_content_width(context, box):
"""Return the *-content width for a ``TableColumnGroupBox``."""
width = box.style['width']
if width == 'auto' or width.unit == '%':
width = 0
else:
assert width.unit == 'px'
width = width.value
return adjust(box, False, width)
def table_cell_min_content_width(context, box, outer):
"""Return the min-content width for a ``TableCellBox``."""
# See https://www.w3.org/TR/css-tables-3/#outer-min-content
# The outer min-content width of a table-cell is
# max(min-width, min-content width) adjusted by
# the cell intrinsic offsets.
children_widths = [
min_content_width(context, child)
for child in box.children
if not child.is_absolutely_positioned()]
children_min_width = adjust(
box,
outer,
max(children_widths) if children_widths else 0)
return children_min_width
def table_cell_max_content_width(context, box, outer):
"""Return the max-content width for a ``TableCellBox``."""
return max(
table_cell_min_content_width(context, box, outer),
block_max_content_width(context, box, outer))
def inline_line_widths(context, box, outer, is_line_start, minimum,
skip_stack=None, first_line=False):
if isinstance(box, boxes.LineBox):
if box.style['text_indent'].unit == '%':
# TODO: this is wrong, text-indent percentages should be resolved
# before calling this function.
text_indent = 0
else:
text_indent = box.style['text_indent'].value
else:
text_indent = 0
current_line = 0
if skip_stack is None:
skip = 0
else:
(skip, skip_stack), = skip_stack.items()
for child in box.children[skip:]:
if child.is_absolutely_positioned():
continue # Skip
if isinstance(child, boxes.InlineBox):
lines = inline_line_widths(
context, child, outer, is_line_start, minimum, skip_stack,
first_line)
if first_line:
lines = [next(lines)]
else:
lines = list(lines)
if len(lines) == 1:
lines[0] = adjust(child, outer, lines[0])
else:
lines[0] = adjust(child, outer, lines[0], right=False)
lines[-1] = adjust(child, outer, lines[-1], left=False)
elif isinstance(child, boxes.TextBox):
space_collapse = child.style['white_space'] in (
'normal', 'nowrap', 'pre-line')
if skip_stack is None:
skip = 0
else:
(skip, skip_stack), = skip_stack.items()
assert skip_stack is None
child_text = child.text.encode()[(skip or 0):]
if is_line_start and space_collapse:
child_text = child_text.lstrip(b' ')
if minimum and child_text == b' ':
lines = [0, 0]
else:
max_width = 0 if minimum else None
lines = []
resume_index = new_resume_index = 0
while new_resume_index is not None:
resume_index += new_resume_index
_, _, new_resume_index, width, _, _ = (
split_first_line(
child_text[resume_index:].decode(), child.style,
context, max_width, child.justification_spacing,
is_line_start=is_line_start, minimum=True))
lines.append(width)
if first_line:
break
if first_line and new_resume_index:
current_line += lines[0]
break
else:
# https://www.w3.org/TR/css-text-3/#overflow-wrap
# "The line breaking behavior of a replaced element
# or other atomic inline is equivalent to that
# of the Object Replacement Character (U+FFFC)."
# https://www.unicode.org/reports/tr14/#DescriptionOfProperties
# "By default, there is a break opportunity
# both before and after any inline object."
if minimum:
lines = [0, max_content_width(context, child), 0]
else:
lines = [max_content_width(context, child)]
# The first text line goes on the current line
current_line += lines[0]
if len(lines) > 1:
# Forced line break
yield current_line + text_indent
text_indent = 0
if len(lines) > 2:
for line in lines[1:-1]:
yield line
current_line = lines[-1]
is_line_start = lines[-1] == 0
skip_stack = None
yield current_line + text_indent
def _percentage_contribution(box):
"""Return the percentage contribution of a cell, column or column group.
https://dbaron.org/css/intrinsic/#pct-contrib
"""
min_width = (
box.style['min_width'].value if box.style['min_width'] != 'auto' and
box.style['min_width'].unit == '%' else 0)
max_width = (
box.style['max_width'].value if box.style['max_width'] != 'auto' and
box.style['max_width'].unit == '%' else inf)
width = (
box.style['width'].value if box.style['width'] != 'auto' and
box.style['width'].unit == '%' else 0)
return max(min_width, min(width, max_width))
def table_and_columns_preferred_widths(context, box, outer=True):
"""Return content widths for the auto layout table and its columns.
The tuple returned is
``(table_min_content_width, table_max_content_width,
column_min_content_widths, column_max_content_widths,
column_intrinsic_percentages, constrainedness,
total_horizontal_border_spacing, grid)``
https://dbaron.org/css/intrinsic/
"""
from .table import distribute_excess_width
table = box.get_wrapped_table()
result = context.tables.get(table)
if result:
return result[outer]
# Create the grid
grid_width, grid_height = 0, 0
row_number = 0
for row_group in table.children:
for row in row_group.children:
for cell in row.children:
grid_width = max(cell.grid_x + cell.colspan, grid_width)
grid_height = max(row_number + cell.rowspan, grid_height)
row_number += 1
grid = [[None] * grid_width for i in range(grid_height)]
row_number = 0
for row_group in table.children:
for row in row_group.children:
for cell in row.children:
grid[row_number][cell.grid_x] = cell
row_number += 1
zipped_grid = list(zip(*grid))
# Define the total horizontal border spacing
if table.style['border_collapse'] == 'separate' and grid_width > 0:
total_horizontal_border_spacing = (
table.style['border_spacing'][0] *
(1 + len([column for column in zipped_grid if any(column)])))
else:
total_horizontal_border_spacing = 0
if grid_width == 0 or grid_height == 0:
table.children = []
min_width = block_min_content_width(context, table, outer=False)
max_width = block_max_content_width(context, table, outer=False)
outer_min_width = adjust(
box, outer=True, width=block_min_content_width(context, table))
outer_max_width = adjust(
box, outer=True, width=block_max_content_width(context, table))
result = ([], [], [], [], total_horizontal_border_spacing, [])
context.tables[table] = result = {
False: (min_width, max_width, *result),
True: (outer_min_width, outer_max_width, *result),
}
return result[outer]
column_groups = [None] * grid_width
columns = [None] * grid_width
column_number = 0
for column_group in table.column_groups:
for column in column_group.children:
column_groups[column_number] = column_group
columns[column_number] = column
column_number += 1
if column_number == grid_width:
break
else:
continue
break
colspan_cells = []
# Define the intermediate content widths
min_content_widths = [0 for i in range(grid_width)]
max_content_widths = [0 for i in range(grid_width)]
intrinsic_percentages = [0 for i in range(grid_width)]
# Intermediate content widths for span 1
for i in range(grid_width):
for groups in (column_groups, columns):
if groups[i]:
min_content_widths[i] = max(
min_content_widths[i],
min_content_width(context, groups[i]))
max_content_widths[i] = max(
max_content_widths[i],
max_content_width(context, groups[i]))
intrinsic_percentages[i] = max(
intrinsic_percentages[i],
_percentage_contribution(groups[i]))
for cell in zipped_grid[i]:
if cell:
if cell.colspan == 1:
min_content_widths[i] = max(
min_content_widths[i],
min_content_width(context, cell))
max_content_widths[i] = max(
max_content_widths[i],
max_content_width(context, cell))
intrinsic_percentages[i] = max(
intrinsic_percentages[i],
_percentage_contribution(cell))
else:
colspan_cells.append(cell)
# Intermediate content widths for span > 1 is wrong in the 4.1 section, as
# explained in its third issue. Min- and max-content widths are handled by
# the excess width distribution method, and percentages do not distribute
# widths to columns that have originating cells.
# Intermediate intrinsic percentage widths for span > 1
for span in range(1, grid_width):
percentage_contributions = []
for i in range(grid_width):
percentage_contribution = intrinsic_percentages[i]
for j, cell in enumerate(zipped_grid[i]):
indexes = [k for k in range(i + 1) if grid[j][k]]
if not indexes:
continue
origin = max(indexes)
origin_cell = grid[j][origin]
if origin_cell.colspan - 1 != span:
continue
cell_slice = slice(origin, origin + origin_cell.colspan)
baseline_percentage = sum(intrinsic_percentages[cell_slice])
# Cell contribution to intrinsic percentage width
if intrinsic_percentages[i] == 0:
diff = max(
0,
_percentage_contribution(origin_cell) -
baseline_percentage)
other_columns_contributions = [
max_content_widths[j]
for j in range(
origin, origin + origin_cell.colspan)
if intrinsic_percentages[j] == 0]
other_columns_contributions_sum = sum(
other_columns_contributions)
if other_columns_contributions_sum == 0:
if other_columns_contributions:
ratio = 1 / len(other_columns_contributions)
else:
ratio = 1
else:
ratio = (
max_content_widths[i] /
other_columns_contributions_sum)
percentage_contribution = max(
percentage_contribution,
diff * ratio)
percentage_contributions.append(percentage_contribution)
intrinsic_percentages = percentage_contributions
# Define constrainedness
constrainedness = [False for i in range(grid_width)]
for i in range(grid_width):
if (column_groups[i] and column_groups[i].style['width'] != 'auto' and
column_groups[i].style['width'].unit != '%'):
constrainedness[i] = True
continue
if (columns[i] and columns[i].style['width'] != 'auto' and
columns[i].style['width'].unit != '%'):
constrainedness[i] = True
continue
for cell in zipped_grid[i]:
if (cell and cell.colspan == 1 and
cell.style['width'] != 'auto' and
cell.style['width'].unit != '%'):
constrainedness[i] = True
break
intrinsic_percentages = [
min(percentage, 100 - sum(intrinsic_percentages[:i]))
for i, percentage in enumerate(intrinsic_percentages)]
# Max- and min-content widths for span > 1
for cell in colspan_cells:
min_content = min_content_width(context, cell)
max_content = max_content_width(context, cell)
column_slice = slice(cell.grid_x, cell.grid_x + cell.colspan)
columns_min_content = sum(min_content_widths[column_slice])
columns_max_content = sum(max_content_widths[column_slice])
if table.style['border_collapse'] == 'separate':
spacing = (cell.colspan - 1) * table.style['border_spacing'][0]
else:
spacing = 0
if min_content > columns_min_content + spacing:
excess_width = min_content - (columns_min_content + spacing)
distribute_excess_width(
context, zipped_grid, excess_width, min_content_widths,
constrainedness, intrinsic_percentages, max_content_widths,
column_slice)
if max_content > columns_max_content + spacing:
excess_width = max_content - (columns_max_content + spacing)
distribute_excess_width(
context, zipped_grid, excess_width, max_content_widths,
constrainedness, intrinsic_percentages, max_content_widths,
column_slice)
# Calculate the max- and min-content widths of table and columns
small_percentage_contributions = [
max_content_widths[i] / (intrinsic_percentages[i] / 100)
for i in range(grid_width)
if intrinsic_percentages[i]]
large_percentage_contribution_numerator = sum(
max_content_widths[i] for i in range(grid_width)
if intrinsic_percentages[i] == 0)
large_percentage_contribution_denominator = (
(100 - sum(intrinsic_percentages)) / 100)
if large_percentage_contribution_denominator == 0:
if large_percentage_contribution_numerator == 0:
large_percentage_contribution = 0
else:
# "the large percentage contribution of the table [is] an
# infinitely large number if the numerator is nonzero [and] the
# denominator of that ratio is 0."
#
# https://dbaron.org/css/intrinsic/#autotableintrinsic
#
# Please note that "an infinitely large number" is not "infinite",
# and that's probably not a coincindence: putting 'inf' here breaks
# some cases (see #305).
large_percentage_contribution = sys.maxsize
else:
large_percentage_contribution = (
large_percentage_contribution_numerator /
large_percentage_contribution_denominator)
table_min_content_width = (
total_horizontal_border_spacing + sum(min_content_widths))
table_max_content_width = (
total_horizontal_border_spacing + max([
sum(max_content_widths), large_percentage_contribution,
*small_percentage_contributions]))
if table.style['width'] != 'auto' and table.style['width'].unit == 'px':
# "percentages on the following properties are treated instead as
# though they were the following: width: auto"
# https://dbaron.org/css/intrinsic/#outer-intrinsic
table_min_width = table_max_width = table.style['width'].value
else:
table_min_width = table_min_content_width
table_max_width = table_max_content_width
table_min_content_width = max(
table_min_content_width, adjust(
table, outer=False, width=table_min_width))
table_max_content_width = max(
table_max_content_width, adjust(
table, outer=False, width=table_max_width))
table_outer_min_content_width = margin_width(
table, margin_width(box, table_min_content_width))
table_outer_max_content_width = margin_width(
table, margin_width(box, table_max_content_width))
result = (
min_content_widths, max_content_widths, intrinsic_percentages,
constrainedness, total_horizontal_border_spacing, zipped_grid)
context.tables[table] = result = {
False: (table_min_content_width, table_max_content_width, *result),
True: (table_outer_min_content_width, table_outer_max_content_width, *result),
}
return result[outer]
def replaced_min_content_width(box, outer=True):
"""Return the min-content width for an ``InlineReplacedBox``."""
width = box.style['width']
if width == 'auto':
height = box.style['height']
if height == 'auto' or height.unit == '%':
height = 'auto'
else:
assert height.unit == 'px'
height = height.value
if (box.style['max_width'] != 'auto' and
box.style['max_width'].unit == '%'):
# See https://drafts.csswg.org/css-sizing/#intrinsic-contribution
width = 0
else:
image = box.replacement
intrinsic_width, intrinsic_height, intrinsic_ratio = (
image.get_intrinsic_size(
box.style['image_resolution'], box.style['font_size']))
width, _ = default_image_sizing(
intrinsic_width, intrinsic_height, intrinsic_ratio, 'auto',
height, default_width=300, default_height=150)
elif box.style['width'].unit == '%':
# See https://drafts.csswg.org/css-sizing/#intrinsic-contribution
width = 0
else:
assert width.unit == 'px'
width = width.value
return adjust(box, outer, width)
def replaced_max_content_width(box, outer=True):
"""Return the max-content width for an ``InlineReplacedBox``."""
width = box.style['width']
if width == 'auto':
height = box.style['height']
if height == 'auto' or height.unit == '%':
height = 'auto'
else:
assert height.unit == 'px'
height = height.value
image = box.replacement
intrinsic_width, intrinsic_height, intrinsic_ratio = (
image.get_intrinsic_size(
box.style['image_resolution'], box.style['font_size']))
width, _ = default_image_sizing(
intrinsic_width, intrinsic_height, intrinsic_ratio, 'auto', height,
default_width=300, default_height=150)
elif box.style['width'].unit == '%':
# See https://drafts.csswg.org/css-sizing/#intrinsic-contribution
width = 0
else:
assert width.unit == 'px'
width = width.value
return adjust(box, outer, width)
def flex_min_content_width(context, box, outer=True):
"""Return the min-content width for an ``FlexContainerBox``."""
# TODO: use real values, see
# https://www.w3.org/TR/css-flexbox-1/#intrinsic-sizes
min_contents = [
min_content_width(context, child)
for child in box.children if child.is_flex_item]
if not min_contents:
return adjust(box, outer, 0)
if (box.style['flex_direction'].startswith('row') and
box.style['flex_wrap'] == 'nowrap'):
return adjust(box, outer, sum(min_contents))
else:
return adjust(box, outer, max(min_contents))
def flex_max_content_width(context, box, outer=True):
"""Return the max-content width for an ``FlexContainerBox``."""
# TODO: use real values, see
# https://www.w3.org/TR/css-flexbox-1/#intrinsic-sizes
max_contents = [
max_content_width(context, child)
for child in box.children if child.is_flex_item]
if not max_contents:
return adjust(box, outer, 0)
if box.style['flex_direction'].startswith('row'):
return adjust(box, outer, sum(max_contents))
else:
return adjust(box, outer, max(max_contents))
def trailing_whitespace_size(context, box):
"""Return the size of the trailing whitespace of ``box``."""
from .inline import split_first_line, split_text_box
while isinstance(box, (boxes.InlineBox, boxes.LineBox)):
if not box.children:
return 0
box = box.children[-1]
if not (isinstance(box, boxes.TextBox) and box.text and
box.style['white_space'] in ('normal', 'nowrap', 'pre-line')):
return 0
stripped_text = box.text.rstrip(' ')
if box.style['font_size'] == 0 or len(stripped_text) == len(box.text):
return 0
if stripped_text:
resume = 0
while resume is not None:
old_resume = resume
old_box, resume, _ = split_text_box(context, box, None, resume)
assert old_box
stripped_box = box.copy_with_text(stripped_text)
stripped_box, resume, _ = split_text_box(
context, stripped_box, None, old_resume)
if stripped_box is None:
# old_box split just before the trailing spaces
return old_box.width
else:
assert resume is None
return old_box.width - stripped_box.width
else:
_, _, _, width, _, _ = split_first_line(
box.text, box.style, context, None, box.justification_spacing)
return width

View File

@@ -0,0 +1,301 @@
"""Layout for images and other replaced elements.
See https://drafts.csswg.org/css-images-3/#sizing
"""
from .min_max import handle_min_max_height, handle_min_max_width
from .percent import percentage
def default_image_sizing(intrinsic_width, intrinsic_height, intrinsic_ratio,
specified_width, specified_height,
default_width, default_height):
"""Default sizing algorithm for the concrete object size.
Return a ``(concrete_width, concrete_height)`` tuple.
See https://drafts.csswg.org/css-images-3/#default-sizing
"""
if specified_width == 'auto':
specified_width = None
if specified_height == 'auto':
specified_height = None
if specified_width is not None and specified_height is not None:
return specified_width, specified_height
elif specified_width is not None:
return specified_width, (
specified_width / intrinsic_ratio if intrinsic_ratio is not None
else intrinsic_height if intrinsic_height is not None
else default_height)
elif specified_height is not None:
return (
specified_height * intrinsic_ratio if intrinsic_ratio is not None
else intrinsic_width if intrinsic_width is not None
else default_width
), specified_height
else:
if intrinsic_width is not None or intrinsic_height is not None:
return default_image_sizing(
intrinsic_width, intrinsic_height, intrinsic_ratio,
intrinsic_width, intrinsic_height, default_width,
default_height)
else:
return contain_constraint_image_sizing(
default_width, default_height, intrinsic_ratio)
def contain_constraint_image_sizing(constraint_width, constraint_height,
intrinsic_ratio):
"""Contain constraint sizing algorithm for the concrete object size.
Return a ``(concrete_width, concrete_height)`` tuple.
See https://drafts.csswg.org/css-images-3/#contain-constraint
"""
return _constraint_image_sizing(
constraint_width, constraint_height, intrinsic_ratio, cover=False)
def cover_constraint_image_sizing(constraint_width, constraint_height,
intrinsic_ratio):
"""Cover constraint sizing algorithm for the concrete object size.
Return a ``(concrete_width, concrete_height)`` tuple.
See https://drafts.csswg.org/css-images-3/#cover-constraint
"""
return _constraint_image_sizing(
constraint_width, constraint_height, intrinsic_ratio, cover=True)
def _constraint_image_sizing(constraint_width, constraint_height,
intrinsic_ratio, cover):
if intrinsic_ratio is None:
return constraint_width, constraint_height
elif cover ^ (constraint_width > constraint_height * intrinsic_ratio):
return constraint_height * intrinsic_ratio, constraint_height
else:
return constraint_width, constraint_width / intrinsic_ratio
def replacedbox_layout(box):
# TODO: respect box-sizing ?
object_fit = box.style['object_fit']
position = box.style['object_position']
image = box.replacement
intrinsic_width, intrinsic_height, intrinsic_ratio = (
image.get_intrinsic_size(
box.style['image_resolution'], box.style['font_size']))
if None in (intrinsic_width, intrinsic_height):
intrinsic_width, intrinsic_height = contain_constraint_image_sizing(
box.width, box.height, intrinsic_ratio)
if object_fit == 'fill':
draw_width, draw_height = box.width, box.height
else:
if object_fit in ('contain', 'scale-down'):
draw_width, draw_height = contain_constraint_image_sizing(
box.width, box.height, intrinsic_ratio)
elif object_fit == 'cover':
draw_width, draw_height = cover_constraint_image_sizing(
box.width, box.height, intrinsic_ratio)
else:
assert object_fit == 'none', object_fit
draw_width, draw_height = intrinsic_width, intrinsic_height
if object_fit == 'scale-down':
draw_width = min(draw_width, intrinsic_width)
draw_height = min(draw_height, intrinsic_height)
origin_x, position_x, origin_y, position_y = position[0]
ref_x = box.width - draw_width
ref_y = box.height - draw_height
position_x = percentage(position_x, ref_x)
position_y = percentage(position_y, ref_y)
if origin_x == 'right':
position_x = ref_x - position_x
if origin_y == 'bottom':
position_y = ref_y - position_y
position_x += box.content_box_x()
position_y += box.content_box_y()
return draw_width, draw_height, position_x, position_y
@handle_min_max_width
def replaced_box_width(box, containing_block):
"""Set the used width for replaced boxes."""
from .block import block_level_width
width, height, ratio = box.replacement.get_intrinsic_size(
box.style['image_resolution'], box.style['font_size'])
# This algorithm simply follows the different points of the specification:
# https://www.w3.org/TR/CSS21/visudet.html#inline-replaced-width
if box.height == box.width == 'auto':
if width is not None:
# Point #1
box.width = width
elif ratio is not None:
if height is not None:
# Point #2 first part
box.width = height * ratio
else:
# Point #3
block_level_width(box, containing_block)
if box.width == 'auto':
if ratio is not None:
# Point #2 second part
box.width = box.height * ratio
elif width is not None:
# Point #4
box.width = width
else:
# Point #5
# It's pretty useless to rely on device size to set width.
box.width = 300
@handle_min_max_height
def replaced_box_height(box):
"""Compute and set the used height for replaced boxes."""
# https://www.w3.org/TR/CSS21/visudet.html#inline-replaced-height
width, height, ratio = box.replacement.get_intrinsic_size(
box.style['image_resolution'], box.style['font_size'])
# Test 'auto' on the computed width, not the used width
if box.height == box.width == 'auto':
box.height = height
elif box.height == 'auto' and ratio:
box.height = box.width / ratio
if box.height == box.width == 'auto' and height is not None:
box.height = height
elif ratio is not None and box.height == 'auto':
box.height = box.width / ratio
elif box.height == 'auto' and height is not None:
box.height = height
elif box.height == 'auto':
# It's pretty useless to rely on device size to set width.
box.height = 150
def inline_replaced_box_layout(box, containing_block):
"""Lay out an inline :class:`boxes.ReplacedBox` ``box``."""
for side in ('top', 'right', 'bottom', 'left'):
if getattr(box, f'margin_{side}') == 'auto':
setattr(box, f'margin_{side}', 0)
inline_replaced_box_width_height(box, containing_block)
def inline_replaced_box_width_height(box, containing_block):
if box.style['width'] == box.style['height'] == 'auto':
replaced_box_width.without_min_max(box, containing_block)
replaced_box_height.without_min_max(box)
min_max_auto_replaced(box)
else:
replaced_box_width(box, containing_block)
replaced_box_height(box)
def min_max_auto_replaced(box):
"""Resolve min/max constraints on replaced elements with 'auto' sizes."""
width = box.width
height = box.height
min_width = box.min_width
min_height = box.min_height
max_width = max(min_width, box.max_width)
max_height = max(min_height, box.max_height)
# (violation_width, violation_height)
violations = (
'min' if width < min_width else 'max' if width > max_width else '',
'min' if height < min_height else 'max' if height > max_height else '')
# Work around divisions by zero. These are pathological cases anyway.
# TODO: is there a cleaner way?
if width == 0:
width = 1e-6
if height == 0:
height = 1e-6
# ('', ''): nothing to do
if violations == ('max', ''):
box.width = max_width
box.height = max(max_width * height / width, min_height)
elif violations == ('min', ''):
box.width = min_width
box.height = min(min_width * height / width, max_height)
elif violations == ('', 'max'):
box.width = max(max_height * width / height, min_width)
box.height = max_height
elif violations == ('', 'min'):
box.width = min(min_height * width / height, max_width)
box.height = min_height
elif violations == ('max', 'max'):
if max_width / width <= max_height / height:
box.width = max_width
box.height = max(min_height, max_width * height / width)
else:
box.width = max(min_width, max_height * width / height)
box.height = max_height
elif violations == ('min', 'min'):
if min_width / width <= min_height / height:
box.width = min(max_width, min_height * width / height)
box.height = min_height
else:
box.width = min_width
box.height = min(max_height, min_width * height / width)
elif violations == ('min', 'max'):
box.width = min_width
box.height = max_height
elif violations == ('max', 'min'):
box.width = max_width
box.height = min_height
def block_replaced_box_layout(context, box, containing_block):
"""Lay out the block :class:`boxes.ReplacedBox` ``box``."""
from .block import block_level_width
from .float import avoid_collisions
box = box.copy()
if box.style['width'] == box.style['height'] == 'auto':
computed_margins = box.margin_left, box.margin_right
block_replaced_width.without_min_max(
box, containing_block)
replaced_box_height.without_min_max(box)
min_max_auto_replaced(box)
box.margin_left, box.margin_right = computed_margins
block_level_width.without_min_max(box, containing_block)
else:
block_replaced_width(box, containing_block)
replaced_box_height(box)
# Don't collide with floats
# https://www.w3.org/TR/CSS21/visuren.html#floats
box.position_x, box.position_y, _ = avoid_collisions(
context, box, containing_block, outer=False)
resume_at = None
next_page = {'break': 'any', 'page': None}
adjoining_margins = []
collapsing_through = False
return box, resume_at, next_page, adjoining_margins, collapsing_through
@handle_min_max_width
def block_replaced_width(box, containing_block):
from .block import block_level_width
# https://www.w3.org/TR/CSS21/visudet.html#block-replaced-width
replaced_box_width.without_min_max(box, containing_block)
block_level_width.without_min_max(box, containing_block)

File diff suppressed because it is too large Load Diff