From 6e93e95354dc968180b09986fb23c3e35c8cbfe1 Mon Sep 17 00:00:00 2001 From: Guillaume Ayoub Date: Sun, 9 Jun 2024 00:56:43 +0200 Subject: [PATCH] Split draw module into multiple submodules --- weasyprint/draw.py | 1511 ------------------------------- weasyprint/draw/__init__.py | 535 +++++++++++ weasyprint/draw/border.py | 673 ++++++++++++++ weasyprint/draw/color.py | 39 + weasyprint/draw/stack.py | 13 + weasyprint/draw/text.py | 264 ++++++ weasyprint/layout/background.py | 2 +- weasyprint/layout/table.py | 2 +- weasyprint/svg/text.py | 2 +- 9 files changed, 1527 insertions(+), 1514 deletions(-) delete mode 100644 weasyprint/draw.py create mode 100644 weasyprint/draw/__init__.py create mode 100644 weasyprint/draw/border.py create mode 100644 weasyprint/draw/color.py create mode 100644 weasyprint/draw/stack.py create mode 100644 weasyprint/draw/text.py diff --git a/weasyprint/draw.py b/weasyprint/draw.py deleted file mode 100644 index e76e1833..00000000 --- a/weasyprint/draw.py +++ /dev/null @@ -1,1511 +0,0 @@ -"""Take an "after layout" box tree and draw it onto a pydyf stream.""" - -import contextlib -import operator -from colorsys import hsv_to_rgb, rgb_to_hsv -from io import BytesIO -from math import ceil, floor, pi, sqrt, tan -from xml.etree import ElementTree - -from PIL import Image - -from .formatting_structure import boxes -from .images import RasterImage, SVGImage -from .layout import replaced -from .layout.background import BackgroundLayer -from .layout.percent import percentage -from .matrix import Matrix -from .stacking import StackingContext -from .text.ffi import ffi, pango, units_from_double, units_to_double -from .text.fonts import get_hb_object_data -from .text.line_break import get_last_word_end - -SIDES = ('top', 'right', 'bottom', 'left') - - -@contextlib.contextmanager -def stacked(stream): - """Save and restore stream context when used with the ``with`` keyword.""" - stream.push_state() - try: - yield - finally: - stream.pop_state() - - -def get_color(style, key): - value = style[key] - return value if value != 'currentColor' else style['color'] - - -def darken(color): - """Return a darker color.""" - hue, saturation, value = rgb_to_hsv(color.red, color.green, color.blue) - value /= 1.5 - saturation /= 1.25 - return (*hsv_to_rgb(hue, saturation, value), color.alpha) - - -def lighten(color): - """Return a lighter color.""" - hue, saturation, value = rgb_to_hsv(color.red, color.green, color.blue) - value = 1 - (1 - value) / 1.5 - if saturation: - saturation = 1 - (1 - saturation) / 1.25 - return (*hsv_to_rgb(hue, saturation, value), color.alpha) - - -def draw_page(page, stream): - """Draw the given PageBox.""" - marks = page.style['marks'] - stacking_context = StackingContext.from_page(page) - draw_background( - stream, stacking_context.box.background, clip_box=False, - bleed=page.bleed, marks=marks) - set_mask_border(stream, page) - draw_background(stream, page.canvas_background, clip_box=False) - draw_border(stream, page) - draw_stacking_context(stream, stacking_context) - - -def draw_stacking_context(stream, stacking_context): - """Draw a ``stacking_context`` on ``stream``.""" - # See https://www.w3.org/TR/CSS2/zindex.html - with stacked(stream): - box = stacking_context.box - - stream.begin_marked_content(box, mcid=True) - - # apply the viewport_overflow to the html box, see #35 - if box.is_for_root_element and ( - stacking_context.page.style['overflow'] != 'visible'): - rounded_box_path( - stream, stacking_context.page.rounded_padding_box()) - stream.clip() - stream.end() - - if box.is_absolutely_positioned() and box.style['clip']: - top, right, bottom, left = box.style['clip'] - if top == 'auto': - top = 0 - if right == 'auto': - right = 0 - if bottom == 'auto': - bottom = box.border_height() - if left == 'auto': - left = box.border_width() - stream.rectangle( - box.border_box_x() + right, box.border_box_y() + top, - left - right, bottom - top) - stream.clip() - stream.end() - - if box.style['opacity'] < 1: - original_stream = stream - stream = stream.add_group(*stream.page_rectangle) - - if box.transformation_matrix: - if box.transformation_matrix.determinant: - stream.transform(*box.transformation_matrix.values) - else: - stream.end_marked_content() - return - - # Point 1 is done in draw_page - - # Point 2 - if isinstance(box, (boxes.BlockBox, boxes.MarginBox, - boxes.InlineBlockBox, boxes.TableCellBox, - boxes.FlexContainerBox, boxes.ReplacedBox)): - set_mask_border(stream, box) - # The canvas background was removed by layout_backgrounds - draw_background(stream, box.background) - draw_border(stream, box) - - with stacked(stream): - # dont clip the PageBox, see #35 - if box.style['overflow'] != 'visible' and not isinstance( - box, boxes.PageBox): - # Only clip the content and the children: - # - the background is already clipped - # - the border must *not* be clipped - rounded_box_path(stream, box.rounded_padding_box()) - stream.clip() - stream.end() - - # Point 3 - for child_context in stacking_context.negative_z_contexts: - draw_stacking_context(stream, child_context) - - # Point 4 - for block in stacking_context.block_level_boxes: - set_mask_border(stream, block) - - if isinstance(block, boxes.TableBox): - draw_table(stream, block) - else: - draw_background(stream, block.background) - draw_border(stream, block) - - # Point 5 - for child_context in stacking_context.float_contexts: - draw_stacking_context(stream, child_context) - - # Point 6 - if isinstance(box, boxes.InlineBox): - draw_inline_level(stream, stacking_context.page, box) - - # Point 7 - for block in (box, *stacking_context.blocks_and_cells): - if isinstance(block, boxes.ReplacedBox): - draw_replacedbox(stream, block) - elif block.children: - if block != box: - stream.begin_marked_content(block, mcid=True) - if isinstance(block.children[-1], boxes.LineBox): - for child in block.children: - draw_inline_level( - stream, stacking_context.page, child) - if block != box: - stream.end_marked_content() - - # Point 8 - for child_context in stacking_context.zero_z_contexts: - draw_stacking_context(stream, child_context) - - # Point 9 - for child_context in stacking_context.positive_z_contexts: - draw_stacking_context(stream, child_context) - - # Point 10 - draw_outlines(stream, box) - - if box.style['opacity'] < 1: - group_id = stream.id - stream = original_stream - with stacked(stream): - stream.set_alpha(box.style['opacity'], stroke=True, fill=True) - stream.draw_x_object(group_id) - - stream.end_marked_content() - - -def rounded_box_path(stream, radii): - """Draw the path of the border radius box. - - ``widths`` is a tuple of the inner widths (top, right, bottom, left) from - the border box. Radii are adjusted from these values. Default is (0, 0, 0, - 0). - - """ - x, y, w, h, tl, tr, br, bl = radii - - if all(0 in corner for corner in (tl, tr, br, bl)): - # No radius, draw a rectangle - stream.rectangle(x, y, w, h) - return - - r = 0.45 - - stream.move_to(x + tl[0], y) - stream.line_to(x + w - tr[0], y) - stream.curve_to( - x + w - tr[0] * r, y, x + w, y + tr[1] * r, x + w, y + tr[1]) - stream.line_to(x + w, y + h - br[1]) - stream.curve_to( - x + w, y + h - br[1] * r, x + w - br[0] * r, y + h, x + w - br[0], - y + h) - stream.line_to(x + bl[0], y + h) - stream.curve_to( - x + bl[0] * r, y + h, x, y + h - bl[1] * r, x, y + h - bl[1]) - stream.line_to(x, y + tl[1]) - stream.curve_to( - x, y + tl[1] * r, x + tl[0] * r, y, x + tl[0], y) - - -def draw_background(stream, bg, clip_box=True, bleed=None, marks=()): - """Draw the background color and image to a ``document.Stream``. - - If ``clip_box`` is set to ``False``, the background is not clipped to the - border box of the background, but only to the painting area. - - """ - if bg is None: - return - - with stacked(stream): - if clip_box: - for box in bg.layers[-1].clipped_boxes: - rounded_box_path(stream, box) - stream.clip() - stream.end() - - # Background color - if bg.color.alpha > 0: - with stacked(stream): - stream.set_color_rgb(*bg.color[:3]) - stream.set_alpha(bg.color.alpha) - painting_area = bg.layers[-1].painting_area - stream.rectangle(*painting_area) - stream.clip() - stream.end() - stream.rectangle(*painting_area) - stream.fill() - - if bleed and marks: - x, y, width, height = bg.layers[-1].painting_area - half_bleed = {key: value * 0.5 for key, value in bleed.items()} - svg = f''' - - ''' - if 'crop' in marks: - svg += f''' - - - - - - - - - ''' - if 'cross' in marks: - svg += f''' - - - - - - - - - ''' - svg += '' - tree = ElementTree.fromstring(svg) - image = SVGImage(tree, None, None, stream) - # Painting area is the PDF media box - size = (width, height) - position = (x, y) - repeat = ('no-repeat', 'no-repeat') - unbounded = True - painting_area = position + size - positioning_area = (0, 0, width, height) - clipped_boxes = [] - layer = BackgroundLayer( - image, size, position, repeat, unbounded, painting_area, - positioning_area, clipped_boxes) - bg.layers.insert(0, layer) - # Paint in reversed order: first layer is "closest" to the viewer. - for layer in reversed(bg.layers): - draw_background_image(stream, layer, bg.image_rendering) - - -def draw_background_image(stream, layer, image_rendering): - if layer.image is None or 0 in layer.size: - return - - painting_x, painting_y, painting_width, painting_height = ( - layer.painting_area) - positioning_x, positioning_y, positioning_width, positioning_height = ( - layer.positioning_area) - position_x, position_y = layer.position - repeat_x, repeat_y = layer.repeat - image_width, image_height = layer.size - - if repeat_x == 'no-repeat': - # We want at least the whole image_width drawn on sub_surface, but we - # want to be sure it will not be repeated on the painting_width. We - # double the painting width to ensure viewers don't incorrectly bleed - # the edge of the pattern into the painting area. (See #1539.) - repeat_width = max(image_width, 2 * painting_width) - elif repeat_x in ('repeat', 'round'): - # We repeat the image each image_width. - repeat_width = image_width - else: - assert repeat_x == 'space' - n_repeats = floor(positioning_width / image_width) - if n_repeats >= 2: - # The repeat width is the whole positioning width with one image - # removed, divided by (the number of repeated images - 1). This - # way, we get the width of one image + one space. We ignore - # background-position for this dimension. - repeat_width = (positioning_width - image_width) / (n_repeats - 1) - position_x = 0 - else: - # We don't repeat the image. - repeat_width = positioning_width - - # Comments above apply here too. - if repeat_y == 'no-repeat': - repeat_height = max(image_height, 2 * painting_height) - elif repeat_y in ('repeat', 'round'): - repeat_height = image_height - else: - assert repeat_y == 'space' - n_repeats = floor(positioning_height / image_height) - if n_repeats >= 2: - repeat_height = ( - positioning_height - image_height) / (n_repeats - 1) - position_y = 0 - else: - repeat_height = positioning_height - - matrix = Matrix(e=position_x + positioning_x, f=position_y + positioning_y) - matrix @= stream.ctm - pattern = stream.add_pattern( - 0, 0, image_width, image_height, repeat_width, repeat_height, matrix) - group = pattern.add_group(0, 0, repeat_width, repeat_height) - - with stacked(stream): - layer.image.draw(group, image_width, image_height, image_rendering) - pattern.draw_x_object(group.id) - stream.color_space('Pattern') - stream.set_color_special(pattern.id) - if layer.unbounded: - x1, y1, x2, y2 = stream.page_rectangle - stream.rectangle(x1, y1, x2 - x1, y2 - y1) - else: - stream.rectangle( - painting_x, painting_y, painting_width, painting_height) - stream.fill() - - -def styled_color(style, color, side): - if style in ('inset', 'outset'): - do_lighten = (side in ('top', 'left')) ^ (style == 'inset') - return (lighten if do_lighten else darken)(color) - elif style in ('ridge', 'groove'): - if (side in ('top', 'left')) ^ (style == 'ridge'): - return lighten(color), darken(color) - else: - return darken(color), lighten(color) - return color - - -def draw_border(stream, box): - """Draw the box borders and column rules to a ``document.Stream``.""" - - # The box is hidden, easy. - if box.style['visibility'] != 'visible': - return - - # Draw column borders. - columns = ( - isinstance(box, boxes.BlockContainerBox) and ( - box.style['column_width'] != 'auto' or - box.style['column_count'] != 'auto')) - if columns and box.style['column_rule_width']: - border_widths = (0, 0, 0, box.style['column_rule_width']) - skip_next = True - for child in box.children: - if child.style['column_span'] == 'all': - skip_next = True - continue - elif skip_next: - skip_next = False - continue - with stacked(stream): - gap = percentage(box.style['column_gap'], box.width) - position_x = (child.position_x - ( - box.style['column_rule_width'] + gap) / 2) - border_box = ( - position_x, child.position_y, - box.style['column_rule_width'], child.height) - clip_border_segment( - stream, box.style['column_rule_style'], - box.style['column_rule_width'], 'left', border_box, - border_widths) - draw_rect_border( - stream, border_box, border_widths, - box.style['column_rule_style'], styled_color( - box.style['column_rule_style'], - get_color(box.style, 'column_rule_color'), 'left')) - - # If there's a border image, that takes precedence. - if box.style['border_image_source'][0] != 'none' and box.border_image is not None: - draw_border_image( - box, stream, box.border_image, box.style['border_image_slice'], - box.style['border_image_repeat'], box.style['border_image_outset'], - box.style['border_image_width']) - return - - widths = [getattr(box, f'border_{side}_width') for side in SIDES] - - if set(widths) == {0}: - # No border, return early. - return - - colors = [get_color(box.style, f'border_{side}_color') for side in SIDES] - styles = [ - colors[i].alpha and box.style[f'border_{side}_style'] - for (i, side) in enumerate(SIDES)] - - simple_style = set(styles) in ({'solid'}, {'double'}) # one style, simple lines - single_color = len(set(colors)) == 1 # one color - four_sides = 0 not in widths # no 0-width border, to avoid PDF artifacts - if simple_style and single_color and four_sides: - # Simple case, we only draw rounded rectangles. - draw_rounded_border(stream, box, styles[0], colors[0]) - return - - # We're not smart enough to find a good way to draw the borders, we must - # draw them side by side. Order is not specified, but this one seems to be - # close to what other browsers do. - values = tuple(zip(SIDES, widths, colors, styles)) - for index in (2, 3, 1, 0): - side, width, color, style = values[index] - if width == 0 or not color: - continue - with stacked(stream): - clip_border_segment( - stream, style, width, side, box.rounded_border_box()[:4], - widths, box.rounded_border_box()[4:]) - draw_rounded_border( - stream, box, style, styled_color(style, color, side)) - - -def draw_border_image(box, stream, image, border_slice, border_repeat, border_outset, - border_width): - """Draw ``image`` as a border image for ``box`` on ``stream`` as specified.""" - # Shared by border-image-* and mask-border-* - width, height, ratio = image.get_intrinsic_size( - box.style['image_resolution'], box.style['font_size']) - intrinsic_width, intrinsic_height = replaced.default_image_sizing( - width, height, ratio, specified_width=None, specified_height=None, - default_width=box.border_width(), default_height=box.border_height()) - - image_slice = border_slice[:4] - should_fill = border_slice[4] - - def compute_slice_dimension(dimension, intrinsic): - if isinstance(dimension, (int, float)): - return min(dimension, intrinsic) - else: - assert dimension.unit == '%' - return min(100, dimension.value) / 100 * intrinsic - - slice_top = compute_slice_dimension(image_slice[0], intrinsic_height) - slice_right = compute_slice_dimension(image_slice[1], intrinsic_width) - slice_bottom = compute_slice_dimension(image_slice[2], intrinsic_height) - slice_left = compute_slice_dimension(image_slice[3], intrinsic_width) - - repeat_x, repeat_y = border_repeat - - x, y, w, h, tl, tr, br, bl = box.rounded_border_box() - px, py, pw, ph, ptl, ptr, pbr, pbl = box.rounded_padding_box() - border_left = px - x - border_top = py - y - border_right = w - pw - border_left - border_bottom = h - ph - border_top - - def compute_outset_dimension(dimension, from_border): - if dimension.unit is None: - return dimension.value * from_border - else: - assert dimension.unit == 'px' - return dimension.value - - outset_top = compute_outset_dimension(border_outset[0], border_top) - outset_right = compute_outset_dimension(border_outset[1], border_right) - outset_bottom = compute_outset_dimension(border_outset[2], border_bottom) - outset_left = compute_outset_dimension(border_outset[3], border_left) - - x -= outset_left - y -= outset_top - w += outset_left + outset_right - h += outset_top + outset_bottom - - def compute_width_adjustment(dimension, original, intrinsic, - area_dimension): - if dimension == 'auto': - return intrinsic - elif isinstance(dimension, (int, float)): - return dimension * original - elif dimension.unit == '%': - return dimension.value / 100 * area_dimension - else: - assert dimension.unit == 'px' - return dimension.value - - # We make adjustments to the border_* variables after handling outsets - # because numerical outsets are relative to border-width, not - # border-image-width. Also, the border image area that is used - # for percentage-based border-image-width values includes any expanded - # area due to border-image-outset. - border_top = compute_width_adjustment( - border_width[0], border_top, slice_top, h) - border_right = compute_width_adjustment( - border_width[1], border_right, slice_right, w) - border_bottom = compute_width_adjustment( - border_width[2], border_bottom, slice_bottom, h) - border_left = compute_width_adjustment( - border_width[3], border_left, slice_left, w) - - def draw_border_image_region(x, y, width, height, slice_x, slice_y, slice_width, - slice_height, repeat_x='stretch', repeat_y='stretch', - scale_x=None, scale_y=None): - if 0 in (intrinsic_width, width, slice_width): - scale_x = 0 - else: - extra_dx = 0 - if not scale_x: - scale_x = (height / slice_height) if height and slice_height else 1 - if repeat_x == 'repeat': - n_repeats_x = ceil(width / slice_width / scale_x) - elif repeat_x == 'space': - n_repeats_x = floor(width / slice_width / scale_x) - # Space is before the first repeat and after the last, - # so there's one more space than repeat. - extra_dx = ( - (width / scale_x - n_repeats_x * slice_width) / (n_repeats_x + 1)) - elif repeat_x == 'round': - n_repeats_x = max(1, round(width / slice_width / scale_x)) - scale_x = width / (n_repeats_x * slice_width) - else: - n_repeats_x = 1 - scale_x = width / slice_width - - if 0 in (intrinsic_height, height, slice_height): - scale_y = 0 - else: - extra_dy = 0 - if not scale_y: - scale_y = (width / slice_width) if width and slice_width else 1 - if repeat_y == 'repeat': - n_repeats_y = ceil(height / slice_height / scale_y) - elif repeat_y == 'space': - n_repeats_y = floor(height / slice_height / scale_y) - # Space is before the first repeat and after the last, - # so there's one more space than repeat. - extra_dy = ( - (height / scale_y - n_repeats_y * slice_height) / (n_repeats_y + 1)) - elif repeat_y == 'round': - n_repeats_y = max(1, round(height / slice_height / scale_y)) - scale_y = height / (n_repeats_y * slice_height) - else: - n_repeats_y = 1 - scale_y = height / slice_height - - if 0 in (scale_x, scale_y): - return scale_x, scale_y - - rendered_width = intrinsic_width * scale_x - rendered_height = intrinsic_height * scale_y - offset_x = rendered_width * slice_x / intrinsic_width - offset_y = rendered_height * slice_y / intrinsic_height - - with stacked(stream): - stream.rectangle(x, y, width, height) - stream.clip() - stream.end() - stream.transform(e=x - offset_x + extra_dx, f=y - offset_y + extra_dy) - stream.transform(a=scale_x, d=scale_y) - for i in range(n_repeats_x): - for j in range(n_repeats_y): - with stacked(stream): - translate_x = i * (slice_width + extra_dx) - translate_y = j * (slice_height + extra_dy) - stream.transform(e=translate_x, f=translate_y) - stream.rectangle( - offset_x / scale_x, offset_y / scale_y, - slice_width, slice_height) - stream.clip() - stream.end() - image.draw( - stream, intrinsic_width, intrinsic_height, - box.style['image_rendering']) - - return scale_x, scale_y - - # Top left. - scale_left, scale_top = draw_border_image_region( - x, y, border_left, border_top, 0, 0, slice_left, slice_top) - # Top right. - draw_border_image_region( - x + w - border_right, y, border_right, border_top, - intrinsic_width - slice_right, 0, slice_right, slice_top) - # Bottom right. - scale_right, scale_bottom = draw_border_image_region( - x + w - border_right, y + h - border_bottom, border_right, border_bottom, - intrinsic_width - slice_right, intrinsic_height - slice_bottom, - slice_right, slice_bottom) - # Bottom left. - draw_border_image_region( - x, y + h - border_bottom, border_left, border_bottom, - 0, intrinsic_height - slice_bottom, slice_left, slice_bottom) - if x_middle := slice_left + slice_right < intrinsic_width: - # Top middle. - draw_border_image_region( - x + border_left, y, w - border_left - border_right, border_top, - slice_left, 0, intrinsic_width - slice_left - slice_right, - slice_top, repeat_x=repeat_x) - # Bottom middle. - draw_border_image_region( - x + border_left, y + h - border_bottom, - w - border_left - border_right, border_bottom, - slice_left, intrinsic_height - slice_bottom, - intrinsic_width - slice_left - slice_right, slice_bottom, - repeat_x=repeat_x) - if y_middle := slice_top + slice_bottom < intrinsic_height: - # Right middle. - draw_border_image_region( - x + w - border_right, y + border_top, - border_right, h - border_top - border_bottom, - intrinsic_width - slice_right, slice_top, - slice_right, intrinsic_height - slice_top - slice_bottom, - repeat_y=repeat_y) - # Left middle. - draw_border_image_region( - x, y + border_top, border_left, h - border_top - border_bottom, - 0, slice_top, slice_left, - intrinsic_height - slice_top - slice_bottom, - repeat_y=repeat_y) - if should_fill and x_middle and y_middle: - # Fill middle. - draw_border_image_region( - x + border_left, y + border_top, w - border_left - border_right, - h - border_top - border_bottom, slice_left, slice_top, - intrinsic_width - slice_left - slice_right, - intrinsic_height - slice_top - slice_bottom, - repeat_x=repeat_x, repeat_y=repeat_y, - scale_x=scale_left or scale_right, scale_y=scale_top or scale_bottom) - - -def set_mask_border(stream, box): - """Set ``box`` mask border as alpha state on ``stream``.""" - if box.style['mask_border_source'][0] == 'none' or box.mask_border_image is None: - return - x, y, w, h, tl, tr, br, bl = box.rounded_border_box() - matrix = Matrix(e=x, f=y) - matrix @= stream.ctm - mask_stream = stream.set_alpha_state(x, y, w, h, box.style['mask_border_mode']) - draw_border_image( - box, mask_stream, box.mask_border_image, box.style['mask_border_slice'], - box.style['mask_border_repeat'], box.style['mask_border_outset'], - box.style['mask_border_width']) - - -def clip_border_segment(stream, style, width, side, border_box, - border_widths=None, radii=None): - """Clip one segment of box border. - - The strategy is to remove the zones not needed because of the style or the - side before painting. - - """ - bbx, bby, bbw, bbh = border_box - (tlh, tlv), (trh, trv), (brh, brv), (blh, blv) = radii or 4 * ((0, 0),) - bt, br, bb, bl = border_widths or 4 * (width,) - - def transition_point(x1, y1, x2, y2): - """Get the point use for border transition. - - The extra boolean returned is ``True`` if the point is in the padding - box (ie. the padding box is rounded). - - This point is not specified. We must be sure to be inside the rounded - padding box, and in the zone defined in the "transition zone" allowed - by the specification. We chose the corner of the transition zone. It's - easy to get and gives quite good results, but it seems to be different - from what other browsers do. - - """ - return ( - ((x1, y1), True) if abs(x1) > abs(x2) and abs(y1) > abs(y2) - else ((x2, y2), False)) - - def corner_half_length(a, b): - """Return the length of the half of one ellipsis corner. - - Inspired by [Ramanujan, S., "Modular Equations and Approximations to - pi" Quart. J. Pure. Appl. Math., vol. 45 (1913-1914), pp. 350-372], - wonderfully explained by Dr Rob. - - https://mathforum.org/dr.math/faq/formulas/ - - """ - x = (a - b) / (a + b) - return pi / 8 * (a + b) * ( - 1 + 3 * x ** 2 / (10 + sqrt(4 - 3 * x ** 2))) - - if side == 'top': - (px1, py1), rounded1 = transition_point(tlh, tlv, bl, bt) - (px2, py2), rounded2 = transition_point(-trh, trv, -br, bt) - width = bt - way = 1 - angle = 1 - main_offset = bby - elif side == 'right': - (px1, py1), rounded1 = transition_point(-trh, trv, -br, bt) - (px2, py2), rounded2 = transition_point(-brh, -brv, -br, -bb) - width = br - way = 1 - angle = 2 - main_offset = bbx + bbw - elif side == 'bottom': - (px1, py1), rounded1 = transition_point(blh, -blv, bl, -bb) - (px2, py2), rounded2 = transition_point(-brh, -brv, -br, -bb) - width = bb - way = -1 - angle = 3 - main_offset = bby + bbh - elif side == 'left': - (px1, py1), rounded1 = transition_point(tlh, tlv, bl, bt) - (px2, py2), rounded2 = transition_point(blh, -blv, bl, -bb) - width = bl - way = -1 - angle = 4 - main_offset = bbx - - if side in ('top', 'bottom'): - a1, b1 = px1 - bl / 2, way * py1 - width / 2 - a2, b2 = -px2 - br / 2, way * py2 - width / 2 - line_length = bbw - px1 + px2 - length = bbw - stream.move_to(bbx + bbw, main_offset) - stream.line_to(bbx, main_offset) - stream.line_to(bbx + px1, main_offset + py1) - stream.line_to(bbx + bbw + px2, main_offset + py2) - elif side in ('left', 'right'): - a1, b1 = -way * px1 - width / 2, py1 - bt / 2 - a2, b2 = -way * px2 - width / 2, -py2 - bb / 2 - line_length = bbh - py1 + py2 - length = bbh - stream.move_to(main_offset, bby + bbh) - stream.line_to(main_offset, bby) - stream.line_to(main_offset + px1, bby + py1) - stream.line_to(main_offset + px2, bby + bbh + py2) - - if style in ('dotted', 'dashed'): - dash = width if style == 'dotted' else 3 * width - if rounded1 or rounded2: - # At least one of the two corners is rounded - chl1 = corner_half_length(a1, b1) - chl2 = corner_half_length(a2, b2) - length = line_length + chl1 + chl2 - dash_length = round(length / dash) - if rounded1 and rounded2: - # 2x dashes - dash = length / (dash_length + dash_length % 2) - else: - # 2x - 1/2 dashes - dash = length / (dash_length + dash_length % 2 - 0.5) - dashes1 = int(ceil((chl1 - dash / 2) / dash)) - dashes2 = int(ceil((chl2 - dash / 2) / dash)) - line = int(floor(line_length / dash)) - - def draw_dots(dashes, line, way, x, y, px, py, chl): - if not dashes: - return line + 1, 0 - for i in range(0, dashes, 2): - i += 0.5 # half dash - angle1 = ( - ((2 * angle - way) + i * way * dash / chl) / - 4 * pi) - angle2 = (min if way > 0 else max)( - ((2 * angle - way) + (i + 1) * way * dash / chl) / - 4 * pi, - angle * pi / 2) - if side in ('top', 'bottom'): - stream.move_to(x + px, main_offset + py) - stream.line_to( - x + px - way * px * 1 / tan(angle2), main_offset) - stream.line_to( - x + px - way * px * 1 / tan(angle1), main_offset) - elif side in ('left', 'right'): - stream.move_to(main_offset + px, y + py) - stream.line_to( - main_offset, y + py + way * py * tan(angle2)) - stream.line_to( - main_offset, y + py + way * py * tan(angle1)) - if angle2 == angle * pi / 2: - offset = (angle1 - angle2) / (( - ((2 * angle - way) + (i + 1) * way * dash / chl) / - 4 * pi) - angle1) - line += 1 - break - else: - offset = 1 - ( - (angle * pi / 2 - angle2) / (angle2 - angle1)) - return line, offset - - line, offset = draw_dots( - dashes1, line, way, bbx, bby, px1, py1, chl1) - line = draw_dots( - dashes2, line, -way, bbx + bbw, bby + bbh, px2, py2, chl2)[0] - - if line_length > 1e-6: - for i in range(0, line, 2): - i += offset - if side in ('top', 'bottom'): - x1 = max(bbx + px1 + i * dash, bbx + px1) - x2 = min(bbx + px1 + (i + 1) * dash, bbx + bbw + px2) - y1 = main_offset - (width if way < 0 else 0) - y2 = y1 + width - elif side in ('left', 'right'): - y1 = max(bby + py1 + i * dash, bby + py1) - y2 = min(bby + py1 + (i + 1) * dash, bby + bbh + py2) - x1 = main_offset - (width if way > 0 else 0) - x2 = x1 + width - stream.rectangle(x1, y1, x2 - x1, y2 - y1) - else: - # 2x + 1 dashes - stream.clip(even_odd=True) - stream.end() - dash = length / ( - round(length / dash) - (round(length / dash) + 1) % 2) or 1 - for i in range(0, int(round(length / dash)), 2): - if side == 'top': - stream.rectangle(bbx + i * dash, bby, dash, width) - elif side == 'right': - stream.rectangle( - bbx + bbw - width, bby + i * dash, width, dash) - elif side == 'bottom': - stream.rectangle( - bbx + i * dash, bby + bbh - width, dash, width) - elif side == 'left': - stream.rectangle(bbx, bby + i * dash, width, dash) - stream.clip(even_odd=True) - stream.end() - - -def draw_rounded_border(stream, box, style, color): - if style in ('ridge', 'groove'): - stream.set_color_rgb(*color[0][:3]) - stream.set_alpha(color[0][3]) - rounded_box_path(stream, box.rounded_padding_box()) - rounded_box_path(stream, box.rounded_box_ratio(1 / 2)) - stream.fill(even_odd=True) - stream.set_color_rgb(*color[1][:3]) - stream.set_alpha(color[1][3]) - rounded_box_path(stream, box.rounded_box_ratio(1 / 2)) - rounded_box_path(stream, box.rounded_border_box()) - stream.fill(even_odd=True) - return - stream.set_color_rgb(*color[:3]) - stream.set_alpha(color[3]) - rounded_box_path(stream, box.rounded_padding_box()) - if style == 'double': - rounded_box_path(stream, box.rounded_box_ratio(1 / 3)) - rounded_box_path(stream, box.rounded_box_ratio(2 / 3)) - rounded_box_path(stream, box.rounded_border_box()) - stream.fill(even_odd=True) - - -def draw_rect_border(stream, box, widths, style, color): - bbx, bby, bbw, bbh = box - bt, br, bb, bl = widths - if style in ('ridge', 'groove'): - stream.set_color_rgb(*color[0][:3]) - stream.set_alpha(color[0][3]) - stream.rectangle(*box) - stream.rectangle( - bbx + bl / 2, bby + bt / 2, - bbw - (bl + br) / 2, bbh - (bt + bb) / 2) - stream.fill(even_odd=True) - stream.rectangle( - bbx + bl / 2, bby + bt / 2, - bbw - (bl + br) / 2, bbh - (bt + bb) / 2) - stream.rectangle(bbx + bl, bby + bt, bbw - bl - br, bbh - bt - bb) - stream.set_color_rgb(*color[1][:3]) - stream.set_alpha(color[1][3]) - stream.fill(even_odd=True) - return - stream.set_color_rgb(*color[:3]) - stream.set_alpha(color[3]) - stream.rectangle(*box) - if style == 'double': - stream.rectangle( - bbx + bl / 3, bby + bt / 3, - bbw - (bl + br) / 3, bbh - (bt + bb) / 3) - stream.rectangle( - bbx + bl * 2 / 3, bby + bt * 2 / 3, - bbw - (bl + br) * 2 / 3, bbh - (bt + bb) * 2 / 3) - stream.rectangle(bbx + bl, bby + bt, bbw - bl - br, bbh - bt - bb) - stream.fill(even_odd=True) - - -def draw_line(stream, x1, y1, x2, y2, thickness, style, color, offset=0): - assert x1 == x2 or y1 == y2 # Only works for vertical or horizontal lines - - with stacked(stream): - if style not in ('ridge', 'groove'): - stream.set_color_rgb(*color[:3], stroke=True) - stream.set_alpha(color[3], stroke=True) - - if style == 'dashed': - stream.set_dash([5 * thickness], offset) - elif style == 'dotted': - stream.set_dash([thickness], offset) - - if style == 'double': - stream.set_line_width(thickness / 3) - if x1 == x2: - stream.move_to(x1 - thickness / 3, y1) - stream.line_to(x2 - thickness / 3, y2) - stream.move_to(x1 + thickness / 3, y1) - stream.line_to(x2 + thickness / 3, y2) - elif y1 == y2: - stream.move_to(x1, y1 - thickness / 3) - stream.line_to(x2, y2 - thickness / 3) - stream.move_to(x1, y1 + thickness / 3) - stream.line_to(x2, y2 + thickness / 3) - elif style in ('ridge', 'groove'): - stream.set_line_width(thickness / 2) - stream.set_color_rgb(*color[0][:3], stroke=True) - stream.set_alpha(color[0][3], stroke=True) - if x1 == x2: - stream.move_to(x1 + thickness / 4, y1) - stream.line_to(x2 + thickness / 4, y2) - elif y1 == y2: - stream.move_to(x1, y1 + thickness / 4) - stream.line_to(x2, y2 + thickness / 4) - stream.stroke() - stream.set_color_rgb(*color[1][:3], stroke=True) - stream.set_alpha(color[1][3], stroke=True) - if x1 == x2: - stream.move_to(x1 - thickness / 4, y1) - stream.line_to(x2 - thickness / 4, y2) - elif y1 == y2: - stream.move_to(x1, y1 - thickness / 4) - stream.line_to(x2, y2 - thickness / 4) - elif style == 'wavy': - assert y1 == y2 # Only allowed for text decoration - up = 1 - radius = 0.75 * thickness - - stream.rectangle(x1, y1 - 2 * radius, x2 - x1, 4 * radius) - stream.clip() - stream.end() - - x = x1 - offset - stream.move_to(x, y1) - while x < x2: - stream.curve_to( - x + radius / 2, y1 + up * radius, - x + 3 * radius / 2, y1 + up * radius, - x + 2 * radius, y1) - x += 2 * radius - up *= -1 - else: - stream.set_line_width(thickness) - stream.move_to(x1, y1) - stream.line_to(x2, y2) - stream.stroke() - - -def draw_outlines(stream, box): - width = box.style['outline_width'] - color = get_color(box.style, 'outline_color') - style = box.style['outline_style'] - if box.style['visibility'] == 'visible' and width and color.alpha: - outline_box = ( - box.border_box_x() - width, box.border_box_y() - width, - box.border_width() + 2 * width, box.border_height() + 2 * width) - for side in SIDES: - with stacked(stream): - clip_border_segment(stream, style, width, side, outline_box) - draw_rect_border( - stream, outline_box, 4 * (width,), style, - styled_color(style, color, side)) - - for child in box.children: - if isinstance(child, boxes.Box): - draw_outlines(stream, child) - - -def draw_table(stream, table): - # Draw backgrounds - draw_background(stream, table.background) - for column_group in table.column_groups: - draw_background(stream, column_group.background) - for column in column_group.children: - draw_background(stream, column.background) - for row_group in table.children: - draw_background(stream, row_group.background) - for row in row_group.children: - draw_background(stream, row.background) - for cell in row.children: - if (table.style['border_collapse'] == 'collapse' or - cell.style['empty_cells'] == 'show' or - not cell.empty): - draw_background(stream, cell.background) - - # Draw borders - if table.style['border_collapse'] == 'collapse': - return draw_collapsed_borders(stream, table) - draw_border(stream, table) - for row_group in table.children: - for row in row_group.children: - for cell in row.children: - if cell.style['empty_cells'] == 'show' or not cell.empty: - draw_border(stream, cell) - - -def draw_collapsed_borders(stream, table): - """Draw borders of table cells when they collapse.""" - row_heights = [ - row.height for row_group in table.children - for row in row_group.children] - column_widths = table.column_widths - if not (row_heights and column_widths): - # One of the list is empty: don’t bother with empty tables - return - row_positions = [ - row.position_y for row_group in table.children - for row in row_group.children] - column_positions = list(table.column_positions) - grid_height = len(row_heights) - grid_width = len(column_widths) - assert grid_width == len(column_positions) - # Add the end of the last column, but make a copy from the table attr. - if table.style['direction'] == 'ltr': - column_positions.append(column_positions[-1] + column_widths[-1]) - else: - column_positions.insert(0, column_positions[0] + column_widths[0]) - # Add the end of the last row. No copy here, we own this list - row_positions.append(row_positions[-1] + row_heights[-1]) - vertical_borders, horizontal_borders = table.collapsed_border_grid - if table.children[0].is_header: - header_rows = len(table.children[0].children) - else: - header_rows = 0 - if table.children[-1].is_footer: - footer_rows = len(table.children[-1].children) - else: - footer_rows = 0 - skipped_rows = table.skipped_rows - if skipped_rows: - body_rows_offset = skipped_rows - header_rows - else: - body_rows_offset = 0 - original_grid_height = len(vertical_borders) - footer_rows_offset = original_grid_height - grid_height - - def row_number(y, horizontal): - # Examples in comments for 2 headers rows, 5 body rows, 3 footer rows - if header_rows and y < header_rows + int(horizontal): - # Row in header: y < 2 for vertical, y < 3 for horizontal - return y - elif footer_rows and y >= grid_height - footer_rows - int(horizontal): - # Row in footer: y >= 7 for vertical, y >= 6 for horizontal - return y + footer_rows_offset - else: - # Row in body: 2 >= y > 7 for vertical, 3 >= y > 6 for horizontal - return y + body_rows_offset - - segments = [] - - def half_max_width(border_list, yx_pairs, vertical=True): - result = 0 - for y, x in yx_pairs: - if ( - (0 <= y < grid_height and 0 <= x <= grid_width) - if vertical else - (0 <= y <= grid_height and 0 <= x < grid_width) - ): - yy = row_number(y, horizontal=not vertical) - _, (_, width, _) = border_list[yy][x] - result = max(result, width) - return result / 2 - - def add_vertical(x, y): - yy = row_number(y, horizontal=False) - score, (style, width, color) = vertical_borders[yy][x] - if width == 0 or color.alpha == 0: - return - pos_x = column_positions[x] - pos_y1 = row_positions[y] - if y != 0 or not table.skip_cell_border_top: - pos_y1 -= half_max_width(horizontal_borders, [ - (y, x - 1), (y, x)], vertical=False) - pos_y2 = row_positions[y + 1] - if y != grid_height - 1 or not table.skip_cell_border_bottom: - pos_y2 += half_max_width(horizontal_borders, [ - (y + 1, x - 1), (y + 1, x)], vertical=False) - segments.append(( - score, style, width, color, 'left', - (pos_x, pos_y1, 0, pos_y2 - pos_y1))) - - def add_horizontal(x, y): - if y == 0 and table.skip_cell_border_top: - return - if y == grid_height and table.skip_cell_border_bottom: - return - yy = row_number(y, horizontal=True) - score, (style, width, color) = horizontal_borders[yy][x] - if width == 0 or color.alpha == 0: - return - pos_y = row_positions[y] - shift_before = half_max_width(vertical_borders, [(y - 1, x), (y, x)]) - shift_after = half_max_width( - vertical_borders, [(y - 1, x + 1), (y, x + 1)]) - if table.style['direction'] == 'ltr': - pos_x1 = column_positions[x] - shift_before - pos_x2 = column_positions[x + 1] + shift_after - else: - pos_x1 = column_positions[x + 1] - shift_after - pos_x2 = column_positions[x] + shift_before - segments.append(( - score, style, width, color, 'top', - (pos_x1, pos_y, pos_x2 - pos_x1, 0))) - - for x in range(grid_width): - add_horizontal(x, 0) - for y in range(grid_height): - add_vertical(0, y) - for x in range(grid_width): - add_vertical(x + 1, y) - add_horizontal(x, y + 1) - - # Sort bigger scores last (painted later, on top) - # Since the number of different scores is expected to be small compared - # to the number of segments, there should be little changes and Timsort - # should be closer to O(n) than O(n * log(n)) - segments.sort(key=operator.itemgetter(0)) - - for segment in segments: - _, style, width, color, side, border_box = segment - with stacked(stream): - bx, by, bw, bh = border_box - draw_line( - stream, bx, by, bx + bw, by + bh, width, style, - styled_color(style, color, side)) - - -def draw_replacedbox(stream, box): - """Draw the given :class:`boxes.ReplacedBox` to a ``document.Stream``.""" - if box.style['visibility'] != 'visible' or not box.width or not box.height: - return - - draw_width, draw_height, draw_x, draw_y = replaced.replacedbox_layout(box) - if draw_width <= 0 or draw_height <= 0: - return - - with stacked(stream): - stream.set_alpha(1) - stream.transform(e=draw_x, f=draw_y) - with stacked(stream): - # TODO: Use the real intrinsic size here, not affected by - # 'image-resolution'? - box.replacement.draw( - stream, draw_width, draw_height, box.style['image_rendering']) - - -def draw_inline_level(stream, page, box, offset_x=0, text_overflow='clip', - block_ellipsis='none'): - if isinstance(box, StackingContext): - stacking_context = box - assert isinstance(stacking_context.box, ( - boxes.InlineBlockBox, boxes.InlineFlexBox, boxes.InlineGridBox)) - draw_stacking_context(stream, stacking_context) - else: - set_mask_border(stream, box) - draw_background(stream, box.background) - draw_border(stream, box) - if isinstance(box, (boxes.InlineBox, boxes.LineBox)): - link_annotation = None - if isinstance(box, boxes.LineBox): - text_overflow = box.text_overflow - block_ellipsis = box.block_ellipsis - else: - link_annotation = box.link_annotation - ellipsis = 'none' - if link_annotation: - stream.begin_marked_content(box, mcid=True, tag='Link') - for i, child in enumerate(box.children): - if i == len(box.children) - 1: - # Last child - ellipsis = block_ellipsis - if isinstance(child, StackingContext): - child_offset_x = offset_x - else: - child_offset_x = ( - offset_x + child.position_x - box.position_x) - if isinstance(child, boxes.TextBox): - draw_text( - stream, child, child_offset_x, text_overflow, ellipsis) - else: - draw_inline_level( - stream, page, child, child_offset_x, text_overflow, - ellipsis) - if link_annotation: - stream.end_marked_content() - elif isinstance(box, boxes.InlineReplacedBox): - draw_replacedbox(stream, box) - else: - assert isinstance(box, boxes.TextBox) - # Should only happen for list markers - draw_text(stream, box, offset_x, text_overflow) - - -def draw_text(stream, textbox, offset_x, text_overflow, block_ellipsis): - """Draw a textbox to a pydyf stream.""" - # Pango crashes with font-size: 0 - assert textbox.style['font_size'] - - if textbox.style['visibility'] != 'visible': - return - - text_decoration_values = textbox.style['text_decoration_line'] - text_decoration_color = get_color(textbox.style, 'text_decoration_color') - if 'overline' in text_decoration_values: - thickness = textbox.pango_layout.underline_thickness - offset_y = ( - textbox.baseline - textbox.pango_layout.ascent + thickness / 2) - draw_text_decoration( - stream, textbox, offset_x, offset_y, thickness, - text_decoration_color) - if 'underline' in text_decoration_values: - thickness = textbox.pango_layout.underline_thickness - offset_y = ( - textbox.baseline - textbox.pango_layout.underline_position + - thickness / 2) - draw_text_decoration( - stream, textbox, offset_x, offset_y, thickness, - text_decoration_color) - - x, y = textbox.position_x, textbox.position_y + textbox.baseline - stream.set_color_rgb(*textbox.style['color'][:3]) - stream.set_alpha(textbox.style['color'][3]) - - textbox.pango_layout.reactivate(textbox.style) - stream.begin_text() - emojis = draw_first_line( - stream, textbox, text_overflow, block_ellipsis, Matrix(d=-1, e=x, f=y)) - stream.end_text() - - draw_emojis(stream, textbox.style['font_size'], x, y, emojis) - - if 'line-through' in text_decoration_values: - thickness = textbox.pango_layout.strikethrough_thickness - offset_y = ( - textbox.baseline - textbox.pango_layout.strikethrough_position) - draw_text_decoration( - stream, textbox, offset_x, offset_y, thickness, - text_decoration_color) - - textbox.pango_layout.deactivate() - - -def draw_emojis(stream, font_size, x, y, emojis): - for image, font, a, d, e, f in emojis: - with stacked(stream): - stream.transform(a=a, d=d, e=x + e * font_size, f=y + f) - image.draw(stream, font_size, font_size, None) - - -def draw_first_line(stream, textbox, text_overflow, block_ellipsis, matrix): - """Draw the given ``textbox`` line to the document ``stream``.""" - # Don’t draw lines with only invisible characters - if not textbox.text.strip(): - return [] - - font_size = textbox.style['font_size'] - if font_size < 1e-6: # Default float precision used by pydyf - return [] - - pango.pango_layout_set_single_paragraph_mode( - textbox.pango_layout.layout, True) - - if text_overflow == 'ellipsis' or block_ellipsis != 'none': - assert textbox.pango_layout.max_width is not None - max_width = textbox.pango_layout.max_width - pango.pango_layout_set_width( - textbox.pango_layout.layout, units_from_double(max_width)) - if text_overflow == 'ellipsis': - pango.pango_layout_set_ellipsize( - textbox.pango_layout.layout, pango.PANGO_ELLIPSIZE_END) - else: - if block_ellipsis == 'auto': - ellipsis = '…' - else: - assert block_ellipsis[0] == 'string' - ellipsis = block_ellipsis[1] - - # Remove last word if hyphenated - new_text = textbox.pango_layout.text - if new_text.endswith(textbox.style['hyphenate_character']): - last_word_end = get_last_word_end( - new_text[:-len(textbox.style['hyphenate_character'])], - textbox.style['lang']) - if last_word_end: - new_text = new_text[:last_word_end] - - textbox.pango_layout.set_text(new_text + ellipsis) - - first_line, index = textbox.pango_layout.get_first_line() - - if block_ellipsis != 'none': - while index: - last_word_end = get_last_word_end( - textbox.pango_layout.text[:-len(ellipsis)], - textbox.style['lang']) - if last_word_end is None: - break - new_text = textbox.pango_layout.text[:last_word_end] - textbox.pango_layout.set_text(new_text + ellipsis) - first_line, index = textbox.pango_layout.get_first_line() - - utf8_text = textbox.pango_layout.text.encode() - previous_utf8_position = 0 - stream.text_matrix(*matrix.values) - last_font = None - string = '' - x_advance = 0 - emojis = [] - run = first_line.runs[0] - while run != ffi.NULL: - # Pango objects - glyph_item = run.data - run = run.next - glyph_string = glyph_item.glyphs - glyphs = glyph_string.glyphs - num_glyphs = glyph_string.num_glyphs - offset = glyph_item.item.offset - clusters = glyph_string.log_clusters - - # Font content - pango_font = glyph_item.item.analysis.font - font = stream.add_font(pango_font) - - # Positions of the glyphs in the UTF-8 string - utf8_positions = [offset + clusters[i] for i in range(1, num_glyphs)] - utf8_positions.append(offset + glyph_item.item.length) - - # Go through the run glyphs - if font != last_font: - if string: - stream.show_text(string) - string = '' - stream.set_font_size(font.hash, 1 if font.bitmap else font_size) - last_font = font - string += '<' - for i in range(num_glyphs): - glyph_info = glyphs[i] - glyph = glyph_info.glyph - width = glyph_info.geometry.width - if (glyph == pango.PANGO_GLYPH_EMPTY or - glyph & pango.PANGO_GLYPH_UNKNOWN_FLAG): - string += f'>{-width / font_size}<' - continue - utf8_position = utf8_positions[i] - - offset = glyph_info.geometry.x_offset / font_size - rise = glyph_info.geometry.y_offset / 1000 - if rise: - if string[-1] == '<': - string = string[:-1] - else: - string += '>' - stream.show_text(string) - stream.set_text_rise(-rise) - string = '' - if offset: - string = f'{-offset}' - string += f'<{glyph:02x}>' if font.bitmap else f'<{glyph:04x}>' - stream.show_text(string) - stream.set_text_rise(0) - string = '<' - else: - if offset: - string += f'>{-offset}<' - string += f'{glyph:02x}' if font.bitmap else f'{glyph:04x}' - - # Ink bounding box and logical widths in font - if glyph not in font.widths: - pango.pango_font_get_glyph_extents( - pango_font, glyph, stream.ink_rect, stream.logical_rect) - font.widths[glyph] = int(round( - units_to_double(stream.logical_rect.width * 1000) / - font_size)) - - # Kerning, word spacing, letter spacing - kerning = int( - font.widths[glyph] - - units_to_double(width * 1000) / font_size + - offset) - if kerning: - string += f'>{kerning}<' - - # Mapping between glyphs and characters - if glyph not in font.cmap: - utf8_slice = slice(previous_utf8_position, utf8_position) - font.cmap[glyph] = utf8_text[utf8_slice].decode() - previous_utf8_position = utf8_position - - if font.svg: - svg_data = get_hb_object_data(font.hb_face, 'svg', glyph) - if svg_data: - # Do as explained in specification - # https://learn.microsoft.com/typography/opentype/spec/svg - tree = ElementTree.fromstring(svg_data) - defs = ElementTree.Element('defs') - for child in list(tree): - defs.append(child) - tree.remove(child) - tree.append(defs) - ElementTree.SubElement( - tree, 'use', attrib={'href': f'#glyph{glyph}'}) - image = SVGImage(tree, None, None, stream) - a = d = font.widths[glyph] / 1000 / font.upem * font_size - emojis.append([image, font, a, d, x_advance, 0]) - elif font.png: - png_data = get_hb_object_data(font.hb_font, 'png', glyph) - if png_data: - pillow_image = Image.open(BytesIO(png_data)) - image_id = f'{font.hash}{glyph}' - image = RasterImage(pillow_image, image_id, png_data) - d = font.widths[glyph] / 1000 - a = pillow_image.width / pillow_image.height * d - pango.pango_font_get_glyph_extents( - pango_font, glyph, stream.ink_rect, - stream.logical_rect) - f = units_to_double( - (-stream.logical_rect.y - stream.logical_rect.height)) - f = f / font_size - font_size - emojis.append([image, font, a, d, x_advance, f]) - - x_advance += (font.widths[glyph] + offset - kerning) / 1000 - - # Close the last glyphs list, remove if empty - if string[-1] == '<': - string = string[:-1] - else: - string += '>' - - # Draw text - stream.show_text(string) - - return emojis - - -def draw_text_decoration(stream, textbox, offset_x, offset_y, thickness, - color): - """Draw text-decoration of ``textbox`` to a ``document.Stream``.""" - draw_line( - stream, textbox.position_x, textbox.position_y + offset_y, - textbox.position_x + textbox.width, textbox.position_y + offset_y, - thickness, textbox.style['text_decoration_style'], color, offset_x) diff --git a/weasyprint/draw/__init__.py b/weasyprint/draw/__init__.py new file mode 100644 index 00000000..12e7ef45 --- /dev/null +++ b/weasyprint/draw/__init__.py @@ -0,0 +1,535 @@ +"""Take an "after layout" box tree and draw it onto a pydyf stream.""" + +import operator +from math import floor +from xml.etree import ElementTree + +from ..formatting_structure import boxes +from ..images import SVGImage +from ..layout import replaced +from ..layout.background import BackgroundLayer +from ..matrix import Matrix +from ..stacking import StackingContext +from .border import draw_border, draw_line, draw_outline, rounded_box, set_mask_border +from .color import styled_color +from .stack import stacked +from .text import draw_text + + +def draw_page(page, stream): + """Draw the given PageBox.""" + marks = page.style['marks'] + stacking_context = StackingContext.from_page(page) + draw_background( + stream, stacking_context.box.background, clip_box=False, bleed=page.bleed, + marks=marks) + set_mask_border(stream, page) + draw_background(stream, page.canvas_background, clip_box=False) + draw_border(stream, page) + draw_stacking_context(stream, stacking_context) + + +def draw_stacking_context(stream, stacking_context): + """Draw a ``stacking_context`` on ``stream``.""" + # See https://www.w3.org/TR/CSS2/zindex.html. + with stacked(stream): + box = stacking_context.box + + stream.begin_marked_content(box, mcid=True) + + # Apply the viewport_overflow to the html box, see #35. + if box.is_for_root_element and ( + stacking_context.page.style['overflow'] != 'visible'): + rounded_box(stream, stacking_context.page.rounded_padding_box()) + stream.clip() + stream.end() + + if box.is_absolutely_positioned() and box.style['clip']: + top, right, bottom, left = box.style['clip'] + if top == 'auto': + top = 0 + if right == 'auto': + right = 0 + if bottom == 'auto': + bottom = box.border_height() + if left == 'auto': + left = box.border_width() + stream.rectangle( + box.border_box_x() + right, box.border_box_y() + top, + left - right, bottom - top) + stream.clip() + stream.end() + + if box.style['opacity'] < 1: + original_stream = stream + stream = stream.add_group(*stream.page_rectangle) + + if box.transformation_matrix: + if box.transformation_matrix.determinant: + stream.transform(*box.transformation_matrix.values) + else: + stream.end_marked_content() + return + + # Point 1 is done in draw_page. + + # Point 2. + if isinstance(box, (boxes.BlockBox, boxes.MarginBox, + boxes.InlineBlockBox, boxes.TableCellBox, + boxes.FlexContainerBox, boxes.ReplacedBox)): + set_mask_border(stream, box) + # The canvas background was removed by layout_backgrounds + draw_background(stream, box.background) + draw_border(stream, box) + + with stacked(stream): + # Dont clip the page box, see #35. + clip = ( + box.style['overflow'] != 'visible' and + not isinstance(box, boxes.PageBox)) + if clip: + # Only clip the content and the children: + # - the background is already clipped, + # - the border must *not* be clipped. + rounded_box(stream, box.rounded_padding_box()) + stream.clip() + stream.end() + + # Point 3. + for child_context in stacking_context.negative_z_contexts: + draw_stacking_context(stream, child_context) + + # Point 4. + for block in stacking_context.block_level_boxes: + set_mask_border(stream, block) + + if isinstance(block, boxes.TableBox): + draw_table(stream, block) + else: + draw_background(stream, block.background) + draw_border(stream, block) + + # Point 5. + for child_context in stacking_context.float_contexts: + draw_stacking_context(stream, child_context) + + # Point 6. + if isinstance(box, boxes.InlineBox): + draw_inline_level(stream, stacking_context.page, box) + + # Point 7. + for block in (box, *stacking_context.blocks_and_cells): + if isinstance(block, boxes.ReplacedBox): + draw_replacedbox(stream, block) + elif block.children: + if block != box: + stream.begin_marked_content(block, mcid=True) + if isinstance(block.children[-1], boxes.LineBox): + for child in block.children: + draw_inline_level(stream, stacking_context.page, child) + if block != box: + stream.end_marked_content() + + # Point 8. + for child_context in stacking_context.zero_z_contexts: + draw_stacking_context(stream, child_context) + + # Point 9. + for child_context in stacking_context.positive_z_contexts: + draw_stacking_context(stream, child_context) + + # Point 10. + draw_outline(stream, box) + + if box.style['opacity'] < 1: + group_id = stream.id + stream = original_stream + with stacked(stream): + stream.set_alpha(box.style['opacity'], stroke=True, fill=True) + stream.draw_x_object(group_id) + + stream.end_marked_content() + + +def draw_background(stream, bg, clip_box=True, bleed=None, marks=()): + """Draw the background color and image to a ``document.Stream``. + + If ``clip_box`` is set to ``False``, the background is not clipped to the + border box of the background, but only to the painting area. + + """ + if bg is None: + return + + with stacked(stream): + if clip_box: + for box in bg.layers[-1].clipped_boxes: + rounded_box(stream, box) + stream.clip() + stream.end() + + # Draw background color. + if bg.color.alpha > 0: + with stacked(stream): + stream.set_color_rgb(*bg.color[:3]) + stream.set_alpha(bg.color.alpha) + painting_area = bg.layers[-1].painting_area + stream.rectangle(*painting_area) + stream.clip() + stream.end() + stream.rectangle(*painting_area) + stream.fill() + + # Draw crop marks and crosses. + if bleed and marks: + x, y, width, height = bg.layers[-1].painting_area + half_bleed = {key: value * 0.5 for key, value in bleed.items()} + svg = f''' + + ''' + if 'crop' in marks: + svg += f''' + + + + + + + + + ''' + if 'cross' in marks: + svg += f''' + + + + + + + + + ''' + svg += '' + tree = ElementTree.fromstring(svg) + image = SVGImage(tree, None, None, stream) + # Painting area is the PDF media box + size = (width, height) + position = (x, y) + repeat = ('no-repeat', 'no-repeat') + unbounded = True + painting_area = position + size + positioning_area = (0, 0, width, height) + clipped_boxes = [] + layer = BackgroundLayer( + image, size, position, repeat, unbounded, painting_area, + positioning_area, clipped_boxes) + bg.layers.insert(0, layer) + # Paint in reversed order: first layer is "closest" to the viewer. + for layer in reversed(bg.layers): + draw_background_image(stream, layer, bg.image_rendering) + + +def draw_background_image(stream, layer, image_rendering): + if layer.image is None or 0 in layer.size: + return + + painting_x, painting_y, painting_width, painting_height = layer.painting_area + positioning_x, positioning_y, positioning_width, positioning_height = ( + layer.positioning_area) + position_x, position_y = layer.position + repeat_x, repeat_y = layer.repeat + image_width, image_height = layer.size + + if repeat_x == 'no-repeat': + # We want at least the whole image_width drawn on sub_surface, but we + # want to be sure it will not be repeated on the painting_width. We + # double the painting width to ensure viewers don't incorrectly bleed + # the edge of the pattern into the painting area. (See #1539.) + repeat_width = max(image_width, 2 * painting_width) + elif repeat_x in ('repeat', 'round'): + # We repeat the image each image_width. + repeat_width = image_width + else: + assert repeat_x == 'space' + n_repeats = floor(positioning_width / image_width) + if n_repeats >= 2: + # The repeat width is the whole positioning width with one image + # removed, divided by (the number of repeated images - 1). This + # way, we get the width of one image + one space. We ignore + # background-position for this dimension. + repeat_width = (positioning_width - image_width) / (n_repeats - 1) + position_x = 0 + else: + # We don't repeat the image. + repeat_width = positioning_width + + # Comments above apply here too. + if repeat_y == 'no-repeat': + repeat_height = max(image_height, 2 * painting_height) + elif repeat_y in ('repeat', 'round'): + repeat_height = image_height + else: + assert repeat_y == 'space' + n_repeats = floor(positioning_height / image_height) + if n_repeats >= 2: + repeat_height = (positioning_height - image_height) / (n_repeats - 1) + position_y = 0 + else: + repeat_height = positioning_height + + matrix = Matrix(e=position_x + positioning_x, f=position_y + positioning_y) + matrix @= stream.ctm + pattern = stream.add_pattern( + 0, 0, image_width, image_height, repeat_width, repeat_height, matrix) + group = pattern.add_group(0, 0, repeat_width, repeat_height) + + with stacked(stream): + layer.image.draw(group, image_width, image_height, image_rendering) + pattern.draw_x_object(group.id) + stream.color_space('Pattern') + stream.set_color_special(pattern.id) + if layer.unbounded: + x1, y1, x2, y2 = stream.page_rectangle + stream.rectangle(x1, y1, x2 - x1, y2 - y1) + else: + stream.rectangle(painting_x, painting_y, painting_width, painting_height) + stream.fill() + + +def draw_table(stream, table): + # Draw backgrounds. + draw_background(stream, table.background) + for column_group in table.column_groups: + draw_background(stream, column_group.background) + for column in column_group.children: + draw_background(stream, column.background) + for row_group in table.children: + draw_background(stream, row_group.background) + for row in row_group.children: + draw_background(stream, row.background) + for cell in row.children: + draw_cell_background = ( + table.style['border_collapse'] == 'collapse' or + cell.style['empty_cells'] == 'show' or + not cell.empty) + if draw_cell_background: + draw_background(stream, cell.background) + + # Draw borders. + if table.style['border_collapse'] == 'collapse': + return draw_collapsed_borders(stream, table) + draw_border(stream, table) + for row_group in table.children: + for row in row_group.children: + for cell in row.children: + if cell.style['empty_cells'] == 'show' or not cell.empty: + draw_border(stream, cell) + + +def draw_collapsed_borders(stream, table): + """Draw borders of table cells when they collapse.""" + row_heights = [ + row.height for row_group in table.children + for row in row_group.children] + column_widths = table.column_widths + if not (row_heights and column_widths): + # One of the list is empty: don’t bother with empty tables. + return + row_positions = [ + row.position_y for row_group in table.children + for row in row_group.children] + column_positions = list(table.column_positions) + grid_height = len(row_heights) + grid_width = len(column_widths) + assert grid_width == len(column_positions) + # Add the end of the last column, but make a copy from the table attr. + if table.style['direction'] == 'ltr': + column_positions.append(column_positions[-1] + column_widths[-1]) + else: + column_positions.insert(0, column_positions[0] + column_widths[0]) + # Add the end of the last row. No copy here, we own this list. + row_positions.append(row_positions[-1] + row_heights[-1]) + vertical_borders, horizontal_borders = table.collapsed_border_grid + if table.children[0].is_header: + header_rows = len(table.children[0].children) + else: + header_rows = 0 + if table.children[-1].is_footer: + footer_rows = len(table.children[-1].children) + else: + footer_rows = 0 + skipped_rows = table.skipped_rows + if skipped_rows: + body_rows_offset = skipped_rows - header_rows + else: + body_rows_offset = 0 + original_grid_height = len(vertical_borders) + footer_rows_offset = original_grid_height - grid_height + + def row_number(y, horizontal): + # Examples in comments for 2 headers rows, 5 body rows, 3 footer rows. + if header_rows and y < header_rows + int(horizontal): + # Row in header: y < 2 for vertical, y < 3 for horizontal. + return y + elif footer_rows and y >= grid_height - footer_rows - int(horizontal): + # Row in footer: y >= 7 for vertical, y >= 6 for horizontal. + return y + footer_rows_offset + else: + # Row in body: 2 >= y > 7 for vertical, 3 >= y > 6 for horizontal. + return y + body_rows_offset + + segments = [] + + def half_max_width(border_list, yx_pairs, vertical=True): + result = 0 + for y, x in yx_pairs: + if vertical: + inside = 0 <= y < grid_height and 0 <= x <= grid_width + else: + inside = 0 <= y <= grid_height and 0 <= x < grid_width + if inside: + yy = row_number(y, horizontal=not vertical) + _, (_, width, _) = border_list[yy][x] + result = max(result, width) + return result / 2 + + def add_vertical(x, y): + yy = row_number(y, horizontal=False) + score, (style, width, color) = vertical_borders[yy][x] + if width == 0 or color.alpha == 0: + return + pos_x = column_positions[x] + pos_y1 = row_positions[y] + if y != 0 or not table.skip_cell_border_top: + pos_y1 -= half_max_width( + horizontal_borders, [(y, x - 1), (y, x)], vertical=False) + pos_y2 = row_positions[y + 1] + if y != grid_height - 1 or not table.skip_cell_border_bottom: + pos_y2 += half_max_width( + horizontal_borders, [(y + 1, x - 1), (y + 1, x)], vertical=False) + segments.append(( + score, style, width, color, 'left', (pos_x, pos_y1, 0, pos_y2 - pos_y1))) + + def add_horizontal(x, y): + if y == 0 and table.skip_cell_border_top: + return + if y == grid_height and table.skip_cell_border_bottom: + return + yy = row_number(y, horizontal=True) + score, (style, width, color) = horizontal_borders[yy][x] + if width == 0 or color.alpha == 0: + return + pos_y = row_positions[y] + shift_before = half_max_width(vertical_borders, [(y - 1, x), (y, x)]) + shift_after = half_max_width(vertical_borders, [(y - 1, x + 1), (y, x + 1)]) + if table.style['direction'] == 'ltr': + pos_x1 = column_positions[x] - shift_before + pos_x2 = column_positions[x + 1] + shift_after + else: + pos_x1 = column_positions[x + 1] - shift_after + pos_x2 = column_positions[x] + shift_before + segments.append(( + score, style, width, color, 'top', (pos_x1, pos_y, pos_x2 - pos_x1, 0))) + + for x in range(grid_width): + add_horizontal(x, 0) + for y in range(grid_height): + add_vertical(0, y) + for x in range(grid_width): + add_vertical(x + 1, y) + add_horizontal(x, y + 1) + + # Sort bigger scores last (painted later, on top). + segments.sort(key=operator.itemgetter(0)) + + for segment in segments: + _, style, width, color, side, border_box = segment + with stacked(stream): + bx, by, bw, bh = border_box + color = styled_color(style, color, side) + draw_line(stream, bx, by, bx + bw, by + bh, width, style, color) + + +def draw_replacedbox(stream, box): + """Draw the given :class:`boxes.ReplacedBox` to a ``document.Stream``.""" + if box.style['visibility'] != 'visible' or not box.width or not box.height: + return + + draw_width, draw_height, draw_x, draw_y = replaced.replacedbox_layout(box) + if draw_width <= 0 or draw_height <= 0: + return + + with stacked(stream): + stream.set_alpha(1) + stream.transform(e=draw_x, f=draw_y) + with stacked(stream): + # TODO: Use the real intrinsic size here, not affected by + # 'image-resolution'? + box.replacement.draw( + stream, draw_width, draw_height, box.style['image_rendering']) + + +def draw_inline_level(stream, page, box, offset_x=0, text_overflow='clip', + block_ellipsis='none'): + if isinstance(box, StackingContext): + stacking_context = box + allowed_boxes = (boxes.InlineBlockBox, boxes.InlineFlexBox, boxes.InlineGridBox) + assert isinstance(stacking_context.box, allowed_boxes) + draw_stacking_context(stream, stacking_context) + else: + set_mask_border(stream, box) + draw_background(stream, box.background) + draw_border(stream, box) + if isinstance(box, (boxes.InlineBox, boxes.LineBox)): + link_annotation = None + if isinstance(box, boxes.LineBox): + text_overflow = box.text_overflow + block_ellipsis = box.block_ellipsis + else: + link_annotation = box.link_annotation + ellipsis = 'none' + if link_annotation: + stream.begin_marked_content(box, mcid=True, tag='Link') + for i, child in enumerate(box.children): + if i == len(box.children) - 1: + # Last child + ellipsis = block_ellipsis + if isinstance(child, StackingContext): + child_offset_x = offset_x + else: + child_offset_x = offset_x + child.position_x - box.position_x + if isinstance(child, boxes.TextBox): + draw_text(stream, child, child_offset_x, text_overflow, ellipsis) + else: + draw_inline_level( + stream, page, child, child_offset_x, text_overflow, ellipsis) + if link_annotation: + stream.end_marked_content() + elif isinstance(box, boxes.InlineReplacedBox): + draw_replacedbox(stream, box) + else: + assert isinstance(box, boxes.TextBox) + # Should only happen for list markers. + draw_text(stream, box, offset_x, text_overflow) diff --git a/weasyprint/draw/border.py b/weasyprint/draw/border.py new file mode 100644 index 00000000..1c7e21ea --- /dev/null +++ b/weasyprint/draw/border.py @@ -0,0 +1,673 @@ +"""Draw borders.""" + +from math import ceil, floor, pi, sqrt, tan + +from ..formatting_structure import boxes +from ..layout import replaced +from ..layout.percent import percentage +from ..matrix import Matrix +from .color import get_color, styled_color +from .stack import stacked + +SIDES = ('top', 'right', 'bottom', 'left') + + +def set_mask_border(stream, box): + """Set ``box`` mask border as alpha state on ``stream``.""" + if box.style['mask_border_source'][0] == 'none' or box.mask_border_image is None: + return + x, y, w, h, tl, tr, br, bl = box.rounded_border_box() + matrix = Matrix(e=x, f=y) + matrix @= stream.ctm + mask_stream = stream.set_alpha_state(x, y, w, h, box.style['mask_border_mode']) + draw_border_image( + box, mask_stream, box.mask_border_image, box.style['mask_border_slice'], + box.style['mask_border_repeat'], box.style['mask_border_outset'], + box.style['mask_border_width']) + + +def draw_border(stream, box): + """Draw the box borders and column rules to a ``document.Stream``.""" + + # The box is hidden, easy. + if box.style['visibility'] != 'visible': + return + + # Draw column borders. + columns = ( + isinstance(box, boxes.BlockContainerBox) and ( + box.style['column_width'] != 'auto' or + box.style['column_count'] != 'auto')) + if columns and box.style['column_rule_width']: + border_widths = (0, 0, 0, box.style['column_rule_width']) + skip_next = True + for child in box.children: + if child.style['column_span'] == 'all': + skip_next = True + continue + elif skip_next: + skip_next = False + continue + with stacked(stream): + rule_width = box.style['column_rule_width'] + rule_style = box.style['column_rule_style'] + gap = percentage(box.style['column_gap'], box.width) + position_x = ( + child.position_x - (box.style['column_rule_width'] + gap) / 2) + border_box = (position_x, child.position_y, rule_width, child.height) + clip_border_segment( + stream, rule_style, rule_width, 'left', border_box, border_widths) + color = styled_color( + rule_style, get_color(box.style, 'column_rule_color'), 'left') + draw_rect_border(stream, border_box, border_widths, rule_style, color) + + # If there's a border image, that takes precedence. + if box.style['border_image_source'][0] != 'none' and box.border_image is not None: + draw_border_image( + box, stream, box.border_image, box.style['border_image_slice'], + box.style['border_image_repeat'], box.style['border_image_outset'], + box.style['border_image_width']) + return + + widths = [getattr(box, f'border_{side}_width') for side in SIDES] + + if set(widths) == {0}: + # No border, return early. + return + + colors = [get_color(box.style, f'border_{side}_color') for side in SIDES] + styles = [ + colors[i].alpha and box.style[f'border_{side}_style'] + for (i, side) in enumerate(SIDES)] + + simple_style = set(styles) in ({'solid'}, {'double'}) # one style, simple lines + single_color = len(set(colors)) == 1 # one color + four_sides = 0 not in widths # no 0-width border, to avoid PDF artifacts + if simple_style and single_color and four_sides: + # Simple case, we only draw rounded rectangles. + draw_rounded_border(stream, box, styles[0], colors[0]) + return + + # We're not smart enough to find a good way to draw the borders, we must + # draw them side by side. Order is not specified, but this one seems to be + # close to what other browsers do. + values = tuple(zip(SIDES, widths, colors, styles)) + for index in (2, 3, 1, 0): + side, width, color, style = values[index] + if width == 0 or not color: + continue + with stacked(stream): + clip_border_segment( + stream, style, width, side, box.rounded_border_box()[:4], + widths, box.rounded_border_box()[4:]) + draw_rounded_border( + stream, box, style, styled_color(style, color, side)) + + +def draw_border_image(box, stream, image, border_slice, border_repeat, border_outset, + border_width): + """Draw ``image`` as a border image for ``box`` on ``stream`` as specified.""" + # Shared by border-image-* and mask-border-*. + width, height, ratio = image.get_intrinsic_size( + box.style['image_resolution'], box.style['font_size']) + intrinsic_width, intrinsic_height = replaced.default_image_sizing( + width, height, ratio, specified_width=None, specified_height=None, + default_width=box.border_width(), default_height=box.border_height()) + + image_slice = border_slice[:4] + should_fill = border_slice[4] + + def compute_slice_dimension(dimension, intrinsic): + if isinstance(dimension, (int, float)): + return min(dimension, intrinsic) + else: + assert dimension.unit == '%' + return min(100, dimension.value) / 100 * intrinsic + + slice_top = compute_slice_dimension(image_slice[0], intrinsic_height) + slice_right = compute_slice_dimension(image_slice[1], intrinsic_width) + slice_bottom = compute_slice_dimension(image_slice[2], intrinsic_height) + slice_left = compute_slice_dimension(image_slice[3], intrinsic_width) + + repeat_x, repeat_y = border_repeat + + x, y, w, h, tl, tr, br, bl = box.rounded_border_box() + px, py, pw, ph, ptl, ptr, pbr, pbl = box.rounded_padding_box() + border_left = px - x + border_top = py - y + border_right = w - pw - border_left + border_bottom = h - ph - border_top + + def compute_outset_dimension(dimension, from_border): + if dimension.unit is None: + return dimension.value * from_border + else: + assert dimension.unit == 'px' + return dimension.value + + outset_top = compute_outset_dimension(border_outset[0], border_top) + outset_right = compute_outset_dimension(border_outset[1], border_right) + outset_bottom = compute_outset_dimension(border_outset[2], border_bottom) + outset_left = compute_outset_dimension(border_outset[3], border_left) + + x -= outset_left + y -= outset_top + w += outset_left + outset_right + h += outset_top + outset_bottom + + def compute_width_adjustment(dimension, original, intrinsic, + area_dimension): + if dimension == 'auto': + return intrinsic + elif isinstance(dimension, (int, float)): + return dimension * original + elif dimension.unit == '%': + return dimension.value / 100 * area_dimension + else: + assert dimension.unit == 'px' + return dimension.value + + # We make adjustments to the border_* variables after handling outsets + # because numerical outsets are relative to border-width, not + # border-image-width. Also, the border image area that is used + # for percentage-based border-image-width values includes any expanded + # area due to border-image-outset. + border_top = compute_width_adjustment( + border_width[0], border_top, slice_top, h) + border_right = compute_width_adjustment( + border_width[1], border_right, slice_right, w) + border_bottom = compute_width_adjustment( + border_width[2], border_bottom, slice_bottom, h) + border_left = compute_width_adjustment( + border_width[3], border_left, slice_left, w) + + def draw_border_image_region(x, y, width, height, slice_x, slice_y, slice_width, + slice_height, repeat_x='stretch', repeat_y='stretch', + scale_x=None, scale_y=None): + if 0 in (intrinsic_width, width, slice_width): + scale_x = 0 + else: + extra_dx = 0 + if not scale_x: + scale_x = (height / slice_height) if height and slice_height else 1 + if repeat_x == 'repeat': + n_repeats_x = ceil(width / slice_width / scale_x) + elif repeat_x == 'space': + n_repeats_x = floor(width / slice_width / scale_x) + # Space is before the first repeat and after the last, + # so there's one more space than repeat. + extra_dx = ( + (width / scale_x - n_repeats_x * slice_width) / (n_repeats_x + 1)) + elif repeat_x == 'round': + n_repeats_x = max(1, round(width / slice_width / scale_x)) + scale_x = width / (n_repeats_x * slice_width) + else: + n_repeats_x = 1 + scale_x = width / slice_width + + if 0 in (intrinsic_height, height, slice_height): + scale_y = 0 + else: + extra_dy = 0 + if not scale_y: + scale_y = (width / slice_width) if width and slice_width else 1 + if repeat_y == 'repeat': + n_repeats_y = ceil(height / slice_height / scale_y) + elif repeat_y == 'space': + n_repeats_y = floor(height / slice_height / scale_y) + # Space is before the first repeat and after the last, + # so there's one more space than repeat. + extra_dy = ( + (height / scale_y - n_repeats_y * slice_height) / (n_repeats_y + 1)) + elif repeat_y == 'round': + n_repeats_y = max(1, round(height / slice_height / scale_y)) + scale_y = height / (n_repeats_y * slice_height) + else: + n_repeats_y = 1 + scale_y = height / slice_height + + if 0 in (scale_x, scale_y): + return scale_x, scale_y + + rendered_width = intrinsic_width * scale_x + rendered_height = intrinsic_height * scale_y + offset_x = rendered_width * slice_x / intrinsic_width + offset_y = rendered_height * slice_y / intrinsic_height + + with stacked(stream): + stream.rectangle(x, y, width, height) + stream.clip() + stream.end() + stream.transform(e=x - offset_x + extra_dx, f=y - offset_y + extra_dy) + stream.transform(a=scale_x, d=scale_y) + for i in range(n_repeats_x): + for j in range(n_repeats_y): + with stacked(stream): + translate_x = i * (slice_width + extra_dx) + translate_y = j * (slice_height + extra_dy) + stream.transform(e=translate_x, f=translate_y) + stream.rectangle( + offset_x / scale_x, offset_y / scale_y, + slice_width, slice_height) + stream.clip() + stream.end() + image.draw( + stream, intrinsic_width, intrinsic_height, + box.style['image_rendering']) + + return scale_x, scale_y + + # Top left. + scale_left, scale_top = draw_border_image_region( + x, y, border_left, border_top, 0, 0, slice_left, slice_top) + # Top right. + draw_border_image_region( + x + w - border_right, y, border_right, border_top, + intrinsic_width - slice_right, 0, slice_right, slice_top) + # Bottom right. + scale_right, scale_bottom = draw_border_image_region( + x + w - border_right, y + h - border_bottom, border_right, border_bottom, + intrinsic_width - slice_right, intrinsic_height - slice_bottom, + slice_right, slice_bottom) + # Bottom left. + draw_border_image_region( + x, y + h - border_bottom, border_left, border_bottom, + 0, intrinsic_height - slice_bottom, slice_left, slice_bottom) + if x_middle := slice_left + slice_right < intrinsic_width: + # Top middle. + draw_border_image_region( + x + border_left, y, w - border_left - border_right, border_top, + slice_left, 0, intrinsic_width - slice_left - slice_right, + slice_top, repeat_x=repeat_x) + # Bottom middle. + draw_border_image_region( + x + border_left, y + h - border_bottom, + w - border_left - border_right, border_bottom, + slice_left, intrinsic_height - slice_bottom, + intrinsic_width - slice_left - slice_right, slice_bottom, + repeat_x=repeat_x) + if y_middle := slice_top + slice_bottom < intrinsic_height: + # Right middle. + draw_border_image_region( + x + w - border_right, y + border_top, + border_right, h - border_top - border_bottom, + intrinsic_width - slice_right, slice_top, + slice_right, intrinsic_height - slice_top - slice_bottom, + repeat_y=repeat_y) + # Left middle. + draw_border_image_region( + x, y + border_top, border_left, h - border_top - border_bottom, + 0, slice_top, slice_left, + intrinsic_height - slice_top - slice_bottom, + repeat_y=repeat_y) + if should_fill and x_middle and y_middle: + # Fill middle. + draw_border_image_region( + x + border_left, y + border_top, w - border_left - border_right, + h - border_top - border_bottom, slice_left, slice_top, + intrinsic_width - slice_left - slice_right, + intrinsic_height - slice_top - slice_bottom, + repeat_x=repeat_x, repeat_y=repeat_y, + scale_x=scale_left or scale_right, scale_y=scale_top or scale_bottom) + + +def clip_border_segment(stream, style, width, side, border_box, + border_widths=None, radii=None): + """Clip one segment of box border. + + The strategy is to remove the zones not needed because of the style or the + side before painting. + + """ + bbx, bby, bbw, bbh = border_box + (tlh, tlv), (trh, trv), (brh, brv), (blh, blv) = radii or 4 * ((0, 0),) + bt, br, bb, bl = border_widths or 4 * (width,) + + def transition_point(x1, y1, x2, y2): + """Get the point use for border transition. + + The extra boolean returned is ``True`` if the point is in the padding + box (ie. the padding box is rounded). + + This point is not specified. We must be sure to be inside the rounded + padding box, and in the zone defined in the "transition zone" allowed + by the specification. We chose the corner of the transition zone. It's + easy to get and gives quite good results, but it seems to be different + from what other browsers do. + + """ + return ( + ((x1, y1), True) if abs(x1) > abs(x2) and abs(y1) > abs(y2) + else ((x2, y2), False)) + + def corner_half_length(a, b): + """Return the length of the half of one ellipsis corner. + + Inspired by [Ramanujan, S., "Modular Equations and Approximations to + pi" Quart. J. Pure. Appl. Math., vol. 45 (1913-1914), pp. 350-372], + wonderfully explained by Dr Rob. + + https://mathforum.org/dr.math/faq/formulas/ + + """ + x = (a - b) / (a + b) + return pi / 8 * (a + b) * ( + 1 + 3 * x ** 2 / (10 + sqrt(4 - 3 * x ** 2))) + + if side == 'top': + (px1, py1), rounded1 = transition_point(tlh, tlv, bl, bt) + (px2, py2), rounded2 = transition_point(-trh, trv, -br, bt) + width = bt + way = 1 + angle = 1 + main_offset = bby + elif side == 'right': + (px1, py1), rounded1 = transition_point(-trh, trv, -br, bt) + (px2, py2), rounded2 = transition_point(-brh, -brv, -br, -bb) + width = br + way = 1 + angle = 2 + main_offset = bbx + bbw + elif side == 'bottom': + (px1, py1), rounded1 = transition_point(blh, -blv, bl, -bb) + (px2, py2), rounded2 = transition_point(-brh, -brv, -br, -bb) + width = bb + way = -1 + angle = 3 + main_offset = bby + bbh + elif side == 'left': + (px1, py1), rounded1 = transition_point(tlh, tlv, bl, bt) + (px2, py2), rounded2 = transition_point(blh, -blv, bl, -bb) + width = bl + way = -1 + angle = 4 + main_offset = bbx + + if side in ('top', 'bottom'): + a1, b1 = px1 - bl / 2, way * py1 - width / 2 + a2, b2 = -px2 - br / 2, way * py2 - width / 2 + line_length = bbw - px1 + px2 + length = bbw + stream.move_to(bbx + bbw, main_offset) + stream.line_to(bbx, main_offset) + stream.line_to(bbx + px1, main_offset + py1) + stream.line_to(bbx + bbw + px2, main_offset + py2) + elif side in ('left', 'right'): + a1, b1 = -way * px1 - width / 2, py1 - bt / 2 + a2, b2 = -way * px2 - width / 2, -py2 - bb / 2 + line_length = bbh - py1 + py2 + length = bbh + stream.move_to(main_offset, bby + bbh) + stream.line_to(main_offset, bby) + stream.line_to(main_offset + px1, bby + py1) + stream.line_to(main_offset + px2, bby + bbh + py2) + + if style in ('dotted', 'dashed'): + dash = width if style == 'dotted' else 3 * width + if rounded1 or rounded2: + # At least one of the two corners is rounded + chl1 = corner_half_length(a1, b1) + chl2 = corner_half_length(a2, b2) + length = line_length + chl1 + chl2 + dash_length = round(length / dash) + if rounded1 and rounded2: + # 2x dashes + dash = length / (dash_length + dash_length % 2) + else: + # 2x - 1/2 dashes + dash = length / (dash_length + dash_length % 2 - 0.5) + dashes1 = int(ceil((chl1 - dash / 2) / dash)) + dashes2 = int(ceil((chl2 - dash / 2) / dash)) + line = int(floor(line_length / dash)) + + def draw_dots(dashes, line, way, x, y, px, py, chl): + if not dashes: + return line + 1, 0 + for i in range(0, dashes, 2): + i += 0.5 # half dash + angle1 = ( + ((2 * angle - way) + i * way * dash / chl) / + 4 * pi) + angle2 = (min if way > 0 else max)( + ((2 * angle - way) + (i + 1) * way * dash / chl) / + 4 * pi, + angle * pi / 2) + if side in ('top', 'bottom'): + stream.move_to(x + px, main_offset + py) + stream.line_to( + x + px - way * px * 1 / tan(angle2), main_offset) + stream.line_to( + x + px - way * px * 1 / tan(angle1), main_offset) + elif side in ('left', 'right'): + stream.move_to(main_offset + px, y + py) + stream.line_to( + main_offset, y + py + way * py * tan(angle2)) + stream.line_to( + main_offset, y + py + way * py * tan(angle1)) + if angle2 == angle * pi / 2: + offset = (angle1 - angle2) / (( + ((2 * angle - way) + (i + 1) * way * dash / chl) / + 4 * pi) - angle1) + line += 1 + break + else: + offset = 1 - ( + (angle * pi / 2 - angle2) / (angle2 - angle1)) + return line, offset + + line, offset = draw_dots( + dashes1, line, way, bbx, bby, px1, py1, chl1) + line = draw_dots( + dashes2, line, -way, bbx + bbw, bby + bbh, px2, py2, chl2)[0] + + if line_length > 1e-6: + for i in range(0, line, 2): + i += offset + if side in ('top', 'bottom'): + x1 = max(bbx + px1 + i * dash, bbx + px1) + x2 = min(bbx + px1 + (i + 1) * dash, bbx + bbw + px2) + y1 = main_offset - (width if way < 0 else 0) + y2 = y1 + width + elif side in ('left', 'right'): + y1 = max(bby + py1 + i * dash, bby + py1) + y2 = min(bby + py1 + (i + 1) * dash, bby + bbh + py2) + x1 = main_offset - (width if way > 0 else 0) + x2 = x1 + width + stream.rectangle(x1, y1, x2 - x1, y2 - y1) + else: + # 2x + 1 dashes + stream.clip(even_odd=True) + stream.end() + dash = length / ( + round(length / dash) - (round(length / dash) + 1) % 2) or 1 + for i in range(0, int(round(length / dash)), 2): + if side == 'top': + stream.rectangle(bbx + i * dash, bby, dash, width) + elif side == 'right': + stream.rectangle( + bbx + bbw - width, bby + i * dash, width, dash) + elif side == 'bottom': + stream.rectangle( + bbx + i * dash, bby + bbh - width, dash, width) + elif side == 'left': + stream.rectangle(bbx, bby + i * dash, width, dash) + stream.clip(even_odd=True) + stream.end() + + +def draw_rounded_border(stream, box, style, color): + if style in ('ridge', 'groove'): + stream.set_color_rgb(*color[0][:3]) + stream.set_alpha(color[0][3]) + rounded_box(stream, box.rounded_padding_box()) + rounded_box(stream, box.rounded_box_ratio(1 / 2)) + stream.fill(even_odd=True) + stream.set_color_rgb(*color[1][:3]) + stream.set_alpha(color[1][3]) + rounded_box(stream, box.rounded_box_ratio(1 / 2)) + rounded_box(stream, box.rounded_border_box()) + stream.fill(even_odd=True) + return + stream.set_color_rgb(*color[:3]) + stream.set_alpha(color[3]) + rounded_box(stream, box.rounded_padding_box()) + if style == 'double': + rounded_box(stream, box.rounded_box_ratio(1 / 3)) + rounded_box(stream, box.rounded_box_ratio(2 / 3)) + rounded_box(stream, box.rounded_border_box()) + stream.fill(even_odd=True) + + +def draw_rect_border(stream, box, widths, style, color): + bbx, bby, bbw, bbh = box + bt, br, bb, bl = widths + if style in ('ridge', 'groove'): + stream.set_color_rgb(*color[0][:3]) + stream.set_alpha(color[0][3]) + stream.rectangle(*box) + stream.rectangle( + bbx + bl / 2, bby + bt / 2, + bbw - (bl + br) / 2, bbh - (bt + bb) / 2) + stream.fill(even_odd=True) + stream.rectangle( + bbx + bl / 2, bby + bt / 2, + bbw - (bl + br) / 2, bbh - (bt + bb) / 2) + stream.rectangle(bbx + bl, bby + bt, bbw - bl - br, bbh - bt - bb) + stream.set_color_rgb(*color[1][:3]) + stream.set_alpha(color[1][3]) + stream.fill(even_odd=True) + return + stream.set_color_rgb(*color[:3]) + stream.set_alpha(color[3]) + stream.rectangle(*box) + if style == 'double': + stream.rectangle( + bbx + bl / 3, bby + bt / 3, + bbw - (bl + br) / 3, bbh - (bt + bb) / 3) + stream.rectangle( + bbx + bl * 2 / 3, bby + bt * 2 / 3, + bbw - (bl + br) * 2 / 3, bbh - (bt + bb) * 2 / 3) + stream.rectangle(bbx + bl, bby + bt, bbw - bl - br, bbh - bt - bb) + stream.fill(even_odd=True) + + +def draw_line(stream, x1, y1, x2, y2, thickness, style, color, offset=0): + assert x1 == x2 or y1 == y2 # Only works for vertical or horizontal lines + + with stacked(stream): + if style not in ('ridge', 'groove'): + stream.set_color_rgb(*color[:3], stroke=True) + stream.set_alpha(color[3], stroke=True) + + if style == 'dashed': + stream.set_dash([5 * thickness], offset) + elif style == 'dotted': + stream.set_dash([thickness], offset) + + if style == 'double': + stream.set_line_width(thickness / 3) + if x1 == x2: + stream.move_to(x1 - thickness / 3, y1) + stream.line_to(x2 - thickness / 3, y2) + stream.move_to(x1 + thickness / 3, y1) + stream.line_to(x2 + thickness / 3, y2) + elif y1 == y2: + stream.move_to(x1, y1 - thickness / 3) + stream.line_to(x2, y2 - thickness / 3) + stream.move_to(x1, y1 + thickness / 3) + stream.line_to(x2, y2 + thickness / 3) + elif style in ('ridge', 'groove'): + stream.set_line_width(thickness / 2) + stream.set_color_rgb(*color[0][:3], stroke=True) + stream.set_alpha(color[0][3], stroke=True) + if x1 == x2: + stream.move_to(x1 + thickness / 4, y1) + stream.line_to(x2 + thickness / 4, y2) + elif y1 == y2: + stream.move_to(x1, y1 + thickness / 4) + stream.line_to(x2, y2 + thickness / 4) + stream.stroke() + stream.set_color_rgb(*color[1][:3], stroke=True) + stream.set_alpha(color[1][3], stroke=True) + if x1 == x2: + stream.move_to(x1 - thickness / 4, y1) + stream.line_to(x2 - thickness / 4, y2) + elif y1 == y2: + stream.move_to(x1, y1 - thickness / 4) + stream.line_to(x2, y2 - thickness / 4) + elif style == 'wavy': + assert y1 == y2 # Only allowed for text decoration + up = 1 + radius = 0.75 * thickness + + stream.rectangle(x1, y1 - 2 * radius, x2 - x1, 4 * radius) + stream.clip() + stream.end() + + x = x1 - offset + stream.move_to(x, y1) + while x < x2: + stream.curve_to( + x + radius / 2, y1 + up * radius, + x + 3 * radius / 2, y1 + up * radius, + x + 2 * radius, y1) + x += 2 * radius + up *= -1 + else: + stream.set_line_width(thickness) + stream.move_to(x1, y1) + stream.line_to(x2, y2) + stream.stroke() + + +def draw_outline(stream, box): + width = box.style['outline_width'] + color = get_color(box.style, 'outline_color') + style = box.style['outline_style'] + if box.style['visibility'] == 'visible' and width and color.alpha: + outline_box = ( + box.border_box_x() - width, box.border_box_y() - width, + box.border_width() + 2 * width, box.border_height() + 2 * width) + for side in SIDES: + with stacked(stream): + clip_border_segment(stream, style, width, side, outline_box) + draw_rect_border( + stream, outline_box, 4 * (width,), style, + styled_color(style, color, side)) + + for child in box.children: + if isinstance(child, boxes.Box): + draw_outline(stream, child) + + +def rounded_box(stream, radii): + """Draw the path of the border radius box. + + ``widths`` is a tuple of the inner widths (top, right, bottom, left) from + the border box. Radii are adjusted from these values. Default is (0, 0, 0, + 0). + + """ + x, y, w, h, tl, tr, br, bl = radii + + if all(0 in corner for corner in (tl, tr, br, bl)): + # No radius, draw a rectangle + stream.rectangle(x, y, w, h) + return + + r = 0.45 + + stream.move_to(x + tl[0], y) + stream.line_to(x + w - tr[0], y) + stream.curve_to( + x + w - tr[0] * r, y, x + w, y + tr[1] * r, x + w, y + tr[1]) + stream.line_to(x + w, y + h - br[1]) + stream.curve_to( + x + w, y + h - br[1] * r, x + w - br[0] * r, y + h, x + w - br[0], + y + h) + stream.line_to(x + bl[0], y + h) + stream.curve_to( + x + bl[0] * r, y + h, x, y + h - bl[1] * r, x, y + h - bl[1]) + stream.line_to(x, y + tl[1]) + stream.curve_to( + x, y + tl[1] * r, x + tl[0] * r, y, x + tl[0], y) diff --git a/weasyprint/draw/color.py b/weasyprint/draw/color.py new file mode 100644 index 00000000..5f15c30f --- /dev/null +++ b/weasyprint/draw/color.py @@ -0,0 +1,39 @@ +"""Draw colors.""" + +from colorsys import hsv_to_rgb, rgb_to_hsv + + +def get_color(style, key): + """Return color, taking care of possible currentColor value.""" + value = style[key] + return value if value != 'currentColor' else style['color'] + + +def darken(color): + """Return a darker color.""" + hue, saturation, value = rgb_to_hsv(color.red, color.green, color.blue) + value /= 1.5 + saturation /= 1.25 + return (*hsv_to_rgb(hue, saturation, value), color.alpha) + + +def lighten(color): + """Return a lighter color.""" + hue, saturation, value = rgb_to_hsv(color.red, color.green, color.blue) + value = 1 - (1 - value) / 1.5 + if saturation: + saturation = 1 - (1 - saturation) / 1.25 + return (*hsv_to_rgb(hue, saturation, value), color.alpha) + + +def styled_color(style, color, side): + """Return inset, outset, ridge and groove border colors.""" + if style in ('inset', 'outset'): + do_lighten = (side in ('top', 'left')) ^ (style == 'inset') + return (lighten if do_lighten else darken)(color) + elif style in ('ridge', 'groove'): + if (side in ('top', 'left')) ^ (style == 'ridge'): + return lighten(color), darken(color) + else: + return darken(color), lighten(color) + return color diff --git a/weasyprint/draw/stack.py b/weasyprint/draw/stack.py new file mode 100644 index 00000000..33a2612c --- /dev/null +++ b/weasyprint/draw/stack.py @@ -0,0 +1,13 @@ +"""Drawing stack context manager.""" + +from contextlib import contextmanager + + +@contextmanager +def stacked(stream): + """Save and restore stream context when used with the ``with`` keyword.""" + stream.push_state() + try: + yield + finally: + stream.pop_state() diff --git a/weasyprint/draw/text.py b/weasyprint/draw/text.py new file mode 100644 index 00000000..6b12689d --- /dev/null +++ b/weasyprint/draw/text.py @@ -0,0 +1,264 @@ +"""Draw text.""" + +from io import BytesIO +from xml.etree import ElementTree + +from PIL import Image + +from ..images import RasterImage, SVGImage +from ..matrix import Matrix +from ..text.ffi import ffi, pango, units_from_double, units_to_double +from ..text.fonts import get_hb_object_data +from ..text.line_break import get_last_word_end +from .border import draw_line +from .color import get_color +from .stack import stacked + + +def draw_text(stream, textbox, offset_x, text_overflow, block_ellipsis): + """Draw a textbox to a pydyf stream.""" + # Pango crashes with font-size: 0. + assert textbox.style['font_size'] + + # Don’t draw invisible textboxes. + if textbox.style['visibility'] != 'visible': + return + + # Draw underline and overline. + text_decoration_values = textbox.style['text_decoration_line'] + text_decoration_color = get_color(textbox.style, 'text_decoration_color') + if 'overline' in text_decoration_values: + thickness = textbox.pango_layout.underline_thickness + offset_y = ( + textbox.baseline - textbox.pango_layout.ascent + thickness / 2) + draw_text_decoration( + stream, textbox, offset_x, offset_y, thickness, + text_decoration_color) + if 'underline' in text_decoration_values: + thickness = textbox.pango_layout.underline_thickness + offset_y = ( + textbox.baseline - textbox.pango_layout.underline_position + + thickness / 2) + draw_text_decoration( + stream, textbox, offset_x, offset_y, thickness, + text_decoration_color) + + # Draw text. + x, y = textbox.position_x, textbox.position_y + textbox.baseline + stream.set_color_rgb(*textbox.style['color'][:3]) + stream.set_alpha(textbox.style['color'][3]) + textbox.pango_layout.reactivate(textbox.style) + stream.begin_text() + emojis = draw_first_line( + stream, textbox, text_overflow, block_ellipsis, Matrix(d=-1, e=x, f=y)) + stream.end_text() + + # Draw emojis. + draw_emojis(stream, textbox.style['font_size'], x, y, emojis) + + # Draw line through. + if 'line-through' in text_decoration_values: + thickness = textbox.pango_layout.strikethrough_thickness + offset_y = textbox.baseline - textbox.pango_layout.strikethrough_position + draw_text_decoration( + stream, textbox, offset_x, offset_y, thickness, text_decoration_color) + textbox.pango_layout.deactivate() + + +def draw_emojis(stream, font_size, x, y, emojis): + """Draw list of emojis.""" + for image, font, a, d, e, f in emojis: + with stacked(stream): + stream.transform(a=a, d=d, e=x + e * font_size, f=y + f) + image.draw(stream, font_size, font_size, None) + + +def draw_first_line(stream, textbox, text_overflow, block_ellipsis, matrix): + """Draw the given ``textbox`` line to the document ``stream``.""" + # Don’t draw lines with only invisible characters. + if not textbox.text.strip(): + return [] + + font_size = textbox.style['font_size'] + if font_size < 1e-6: # default float precision used by pydyf + return [] + + pango.pango_layout_set_single_paragraph_mode(textbox.pango_layout.layout, True) + + if text_overflow == 'ellipsis' or block_ellipsis != 'none': + assert textbox.pango_layout.max_width is not None + max_width = textbox.pango_layout.max_width + pango.pango_layout_set_width( + textbox.pango_layout.layout, units_from_double(max_width)) + if text_overflow == 'ellipsis': + pango.pango_layout_set_ellipsize( + textbox.pango_layout.layout, pango.PANGO_ELLIPSIZE_END) + else: + if block_ellipsis == 'auto': + ellipsis = '…' + else: + assert block_ellipsis[0] == 'string' + ellipsis = block_ellipsis[1] + + # Remove last word if hyphenated. + new_text = textbox.pango_layout.text + if new_text.endswith(textbox.style['hyphenate_character']): + last_word_end = get_last_word_end( + new_text[:-len(textbox.style['hyphenate_character'])], + textbox.style['lang']) + if last_word_end: + new_text = new_text[:last_word_end] + + textbox.pango_layout.set_text(new_text + ellipsis) + + first_line, index = textbox.pango_layout.get_first_line() + + if block_ellipsis != 'none': + while index: + last_word_end = get_last_word_end( + textbox.pango_layout.text[:-len(ellipsis)], + textbox.style['lang']) + if last_word_end is None: + break + new_text = textbox.pango_layout.text[:last_word_end] + textbox.pango_layout.set_text(new_text + ellipsis) + first_line, index = textbox.pango_layout.get_first_line() + + utf8_text = textbox.pango_layout.text.encode() + previous_utf8_position = 0 + stream.text_matrix(*matrix.values) + last_font = None + string = '' + x_advance = 0 + emojis = [] + run = first_line.runs[0] + while run != ffi.NULL: + # Get Pango objects. + glyph_item = run.data + run = run.next + glyph_string = glyph_item.glyphs + glyphs = glyph_string.glyphs + num_glyphs = glyph_string.num_glyphs + offset = glyph_item.item.offset + clusters = glyph_string.log_clusters + + # Add font file content. + pango_font = glyph_item.item.analysis.font + font = stream.add_font(pango_font) + + # Get positions of the glyphs in the UTF-8 string. + utf8_positions = [offset + clusters[i] for i in range(1, num_glyphs)] + utf8_positions.append(offset + glyph_item.item.length) + + # Go through the run glyphs. + if font != last_font: + if string: + stream.show_text(string) + string = '' + stream.set_font_size(font.hash, 1 if font.bitmap else font_size) + last_font = font + string += '<' + for i in range(num_glyphs): + glyph_info = glyphs[i] + glyph = glyph_info.glyph + width = glyph_info.geometry.width + if (glyph == pango.PANGO_GLYPH_EMPTY or + glyph & pango.PANGO_GLYPH_UNKNOWN_FLAG): + string += f'>{-width / font_size}<' + continue + utf8_position = utf8_positions[i] + + offset = glyph_info.geometry.x_offset / font_size + rise = glyph_info.geometry.y_offset / 1000 + if rise: + if string[-1] == '<': + string = string[:-1] + else: + string += '>' + stream.show_text(string) + stream.set_text_rise(-rise) + string = '' + if offset: + string = f'{-offset}' + string += f'<{glyph:02x}>' if font.bitmap else f'<{glyph:04x}>' + stream.show_text(string) + stream.set_text_rise(0) + string = '<' + else: + if offset: + string += f'>{-offset}<' + string += f'{glyph:02x}' if font.bitmap else f'{glyph:04x}' + + # Get ink bounding box and logical widths in font. + if glyph not in font.widths: + pango.pango_font_get_glyph_extents( + pango_font, glyph, stream.ink_rect, stream.logical_rect) + font.widths[glyph] = int(round( + units_to_double(stream.logical_rect.width * 1000) / + font_size)) + + # Set kerning, word spacing, letter spacing. + kerning = int( + font.widths[glyph] - units_to_double(width * 1000) / font_size + offset) + if kerning: + string += f'>{kerning}<' + + # Create mapping between glyphs and characters. + if glyph not in font.cmap: + utf8_slice = slice(previous_utf8_position, utf8_position) + font.cmap[glyph] = utf8_text[utf8_slice].decode() + previous_utf8_position = utf8_position + + # Create list of emojis. + if font.svg: + svg_data = get_hb_object_data(font.hb_face, 'svg', glyph) + if svg_data: + # Do as explained in specification + # https://learn.microsoft.com/typography/opentype/spec/svg + tree = ElementTree.fromstring(svg_data) + defs = ElementTree.Element('defs') + for child in list(tree): + defs.append(child) + tree.remove(child) + tree.append(defs) + ElementTree.SubElement( + tree, 'use', attrib={'href': f'#glyph{glyph}'}) + image = SVGImage(tree, None, None, stream) + a = d = font.widths[glyph] / 1000 / font.upem * font_size + emojis.append([image, font, a, d, x_advance, 0]) + elif font.png: + png_data = get_hb_object_data(font.hb_font, 'png', glyph) + if png_data: + pillow_image = Image.open(BytesIO(png_data)) + image_id = f'{font.hash}{glyph}' + image = RasterImage(pillow_image, image_id, png_data) + d = font.widths[glyph] / 1000 + a = pillow_image.width / pillow_image.height * d + pango.pango_font_get_glyph_extents( + pango_font, glyph, stream.ink_rect, + stream.logical_rect) + f = units_to_double( + (-stream.logical_rect.y - stream.logical_rect.height)) + f = f / font_size - font_size + emojis.append([image, font, a, d, x_advance, f]) + + x_advance += (font.widths[glyph] + offset - kerning) / 1000 + + # Close the last glyphs list, remove if empty. + if string[-1] == '<': + string = string[:-1] + else: + string += '>' + + # Draw text. + stream.show_text(string) + + return emojis + + +def draw_text_decoration(stream, textbox, offset_x, offset_y, thickness, color): + """Draw text-decoration of ``textbox`` to a ``document.Stream``.""" + draw_line( + stream, textbox.position_x, textbox.position_y + offset_y, + textbox.position_x + textbox.width, textbox.position_y + offset_y, + thickness, textbox.style['text_decoration_style'], color, offset_x) diff --git a/weasyprint/layout/background.py b/weasyprint/layout/background.py index 3ca357ef..c23b2662 100644 --- a/weasyprint/layout/background.py +++ b/weasyprint/layout/background.py @@ -35,7 +35,7 @@ def box_rectangle(box, which_rectangle): 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 + from ..draw.color import get_color # Resolve percentages in border-radius properties resolve_radii_percentages(box) diff --git a/weasyprint/layout/table.py b/weasyprint/layout/table.py index 9c34d90c..2be5402d 100644 --- a/weasyprint/layout/table.py +++ b/weasyprint/layout/table.py @@ -949,7 +949,7 @@ def collapse_table_borders(table, grid_width, grid_height): [weak_null_border] * grid_width for _ in range(grid_height + 1)] def set_one_border(border_grid, box_style, side, grid_x, grid_y): - from ..draw import get_color + from ..draw.color import get_color style = box_style[f'border_{side}_style'] width = box_style[f'border_{side}_width'] diff --git a/weasyprint/svg/text.py b/weasyprint/svg/text.py index e7b4c683..eb7997f1 100644 --- a/weasyprint/svg/text.py +++ b/weasyprint/svg/text.py @@ -21,7 +21,7 @@ class TextBox: def text(svg, node, font_size): """Draw text node.""" from ..css.properties import INITIAL_VALUES - from ..draw import draw_emojis, draw_first_line + from ..draw.text import draw_emojis, draw_first_line from ..text.line_break import split_first_line # TODO: use real computed values