1
1
mirror of https://github.com/Kozea/WeasyPrint.git synced 2024-10-05 00:21:15 +03:00
WeasyPrint/weasyprint/draw.py
Simon Sapin d310ffe47d Merge branch 'cffi'
Conflicts:
	weasyprint/text.py
2013-02-25 15:23:59 +01:00

673 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# coding: utf8
"""
weasyprint.draw
---------------
Take an "after layout" box tree and draw it onto a cairo context.
:copyright: Copyright 2011-2012 Simon Sapin and contributors, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
from __future__ import division, unicode_literals
import contextlib
import math
import operator
import cairocffi as cairo
from .formatting_structure import boxes
from .layout.backgrounds import box_rectangle
from .stacking import StackingContext
from .text import show_first_line
from .compat import xrange
# Map values of the image-rendering property to cairo FILTER values:
# Values are normalized to lower case.
IMAGE_RENDERING_TO_FILTER = dict(
optimizespeed=cairo.FILTER_FAST,
auto=cairo.FILTER_GOOD,
optimizequality=cairo.FILTER_BEST,
)
@contextlib.contextmanager
def stacked(context):
"""Save and restore the context when used with the ``with`` keyword."""
context.save()
try:
yield
finally:
context.restore()
def lighten(color, offset):
"""Return a lighter color (or darker, for negative offsets)."""
return (
color.red + offset,
color.green + offset,
color.blue + offset,
color.alpha)
def draw_page(page, context, enable_hinting):
"""Draw the given PageBox."""
stacking_context = StackingContext.from_page(page)
draw_background(context, stacking_context.box.background, enable_hinting)
draw_background(context, page.canvas_background, enable_hinting)
draw_border(context, page, enable_hinting)
draw_stacking_context(context, stacking_context, enable_hinting)
def draw_box_background_and_border(context, page, box, enable_hinting):
draw_background(context, box.background, enable_hinting)
if not isinstance(box, boxes.TableBox):
draw_border(context, box, enable_hinting)
else:
for column_group in box.column_groups:
draw_background(context, column_group.background, enable_hinting)
for column in column_group.children:
draw_background(context, column.background, enable_hinting)
for row_group in box.children:
draw_background(context, row_group.background, enable_hinting)
for row in row_group.children:
draw_background(context, row.background, enable_hinting)
for cell in row.children:
draw_background(context, cell.background, enable_hinting)
if box.style.border_collapse == 'separate':
draw_border(context, box, enable_hinting)
for row_group in box.children:
for row in row_group.children:
for cell in row.children:
draw_border(context, cell, enable_hinting)
else:
draw_collapsed_borders(context, box, enable_hinting)
def draw_stacking_context(context, stacking_context, enable_hinting):
"""Draw a ``stacking_context`` on ``context``."""
# See http://www.w3.org/TR/CSS2/zindex.html
with stacked(context):
box = stacking_context.box
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()
context.rectangle(
box.border_box_x() + right,
box.border_box_y() + top,
left - right,
bottom - top)
context.clip()
if box.style.overflow != 'visible':
context.rectangle(*box_rectangle(box, 'padding-box'))
context.clip()
if box.style.opacity < 1:
context.push_group()
if box.transformation_matrix:
context.transform(box.transformation_matrix)
# Point 1 is done in draw_page
# Point 2
if isinstance(box, (boxes.BlockBox, boxes.MarginBox,
boxes.InlineBlockBox)):
# The canvas background was removed by set_canvas_background
draw_box_background_and_border(
context, stacking_context.page, box, enable_hinting)
# Point 3
for child_context in stacking_context.negative_z_contexts:
draw_stacking_context(context, child_context, enable_hinting)
# Point 4
for block in stacking_context.block_level_boxes:
draw_box_background_and_border(
context, stacking_context.page, block, enable_hinting)
# Point 5
for child_context in stacking_context.float_contexts:
draw_stacking_context(context, child_context, enable_hinting)
# Point 6
if isinstance(box, boxes.InlineBox):
draw_inline_level(
context, stacking_context.page, box, enable_hinting)
# Point 7
for block in [box] + stacking_context.blocks_and_cells:
marker_box = getattr(block, 'outside_list_marker', None)
if marker_box:
draw_inline_level(
context, stacking_context.page, marker_box, enable_hinting)
if isinstance(block, boxes.ReplacedBox):
draw_replacedbox(context, block)
else:
for child in block.children:
if isinstance(child, boxes.LineBox):
# TODO: draw inline tables
draw_inline_level(
context, stacking_context.page, child,
enable_hinting)
# Point 8
for child_context in stacking_context.zero_z_contexts:
draw_stacking_context(context, child_context, enable_hinting)
# Point 9
for child_context in stacking_context.positive_z_contexts:
draw_stacking_context(context, child_context, enable_hinting)
# Point 10
draw_outlines(context, box, enable_hinting)
if box.style.opacity < 1:
context.pop_group_to_source()
context.paint_with_alpha(box.style.opacity)
def percentage(value, refer_to):
"""Return the evaluated percentage value, or the value unchanged."""
if value.unit == 'px':
return value.value
else:
assert value.unit == '%'
return refer_to * value.value / 100
def draw_background(context, bg, enable_hinting):
"""Draw the background color and image to a ``cairo.Context``."""
if bg is None:
return
with stacked(context):
if enable_hinting:
# Prefer crisp edges on background rectangles.
context.set_antialias(cairo.ANTIALIAS_NONE)
if bg.painting_area:
context.rectangle(*bg.painting_area)
context.clip()
#else: unrestricted, whole page box
# Background color
if bg.color.alpha > 0:
context.set_source_rgba(*bg.color)
context.paint()
# Background image
if bg.image is None:
return
(positioning_x, positioning_y,
positioning_width, positioning_height) = bg.positioning_area
context.translate(positioning_x, positioning_y)
get_pattern, intrinsic_width, intrinsic_height = bg.image
bg_size = bg.size
if bg_size in ('cover', 'contain'):
scale_x = scale_y = {'cover': max, 'contain': min}[bg_size](
positioning_width / intrinsic_width,
positioning_height / intrinsic_height)
image_width = intrinsic_width * scale_x
image_height = intrinsic_height * scale_y
elif bg_size == ('auto', 'auto'):
scale_x = scale_y = 1
image_width = intrinsic_width
image_height = intrinsic_height
elif bg_size[0] == 'auto':
image_height = percentage(bg_size[1], positioning_height)
scale_x = scale_y = image_height / intrinsic_height
image_width = intrinsic_width * scale_x
elif bg_size[1] == 'auto':
image_width = percentage(bg_size[0], positioning_width)
scale_x = scale_y = image_width / intrinsic_width
image_height = intrinsic_height * scale_y
else:
image_width = percentage(bg_size[0], positioning_width)
image_height = percentage(bg_size[1], positioning_height)
scale_x = image_width / intrinsic_width
scale_y = image_height / intrinsic_height
bg_position_x, bg_position_y = bg.position
context.translate(
percentage(bg_position_x, positioning_width - image_width),
percentage(bg_position_y, positioning_height - image_height),
)
if bg.repeat in ('repeat-x', 'repeat-y'):
# Get the current clip rectangle. This is the same as
# painting_area, but in new coordinates after translate()
clip_x1, clip_y1, clip_x2, clip_y2 = context.clip_extents()
clip_width = clip_x2 - clip_x1
clip_height = clip_y2 - clip_y1
if bg.repeat == 'repeat-x':
# Limit the drawn area vertically
clip_y1 = 0 # because of the last context.translate()
clip_height = image_height
else:
# repeat-y
# Limit the drawn area horizontally
clip_x1 = 0 # because of the last context.translate()
clip_width = image_width
# Second clip for the background image
context.rectangle(clip_x1, clip_y1, clip_width, clip_height)
context.clip()
pattern = get_pattern()
pattern.set_extend(cairo.EXTEND_NONE if bg.repeat == 'no-repeat'
else cairo.EXTEND_REPEAT)
pattern.set_filter(IMAGE_RENDERING_TO_FILTER[bg.image_rendering])
context.scale(scale_x, scale_y)
context.set_source(pattern)
context.paint()
def get_rectangle_edges(x, y, width, height):
"""Return the 4 edges of a rectangle as a list.
Edges are in clock-wise order, starting from the top.
Each edge is returned as ``(start_point, end_point)`` and each point
as ``(x, y)`` coordinates.
"""
# In clock-wise order, starting on top left
corners = [
(x, y),
(x + width, y),
(x + width, y + height),
(x, y + height)]
# clock-wise order, starting on top right
shifted_corners = corners[1:] + corners[:1]
return zip(corners, shifted_corners)
def xy_offset(x, y, offset_x, offset_y, offset):
"""Increment X and Y coordinates by the given offsets."""
return x + offset_x * offset, y + offset_y * offset
def draw_border(context, box, enable_hinting):
"""Draw the box border to a ``cairo.Context``."""
if box.style.visibility == 'hidden':
return
if all(getattr(box, 'border_%s_width' % side) == 0
for side in ['top', 'right', 'bottom', 'left']):
# No border, return early.
return
for side, border_edge, padding_edge in zip(
['top', 'right', 'bottom', 'left'],
get_rectangle_edges(
box.border_box_x(), box.border_box_y(),
box.border_width(), box.border_height(),
),
get_rectangle_edges(
box.padding_box_x(), box.padding_box_y(),
box.padding_width(), box.padding_height(),
),
):
width = getattr(box, 'border_%s_width' % side)
if width == 0:
continue
color = box.style.get_color('border_%s_color' % side)
if color.alpha == 0:
continue
style = box.style['border_%s_style' % side]
draw_border_segment(context, enable_hinting, style, width, color,
side, border_edge, padding_edge)
def draw_border_segment(context, enable_hinting, style, width, color, side,
border_edge, padding_edge):
with stacked(context):
context.set_source_rgba(*color)
x_offset, y_offset = {
'top': (0, 1), 'bottom': (0, -1), 'left': (1, 0), 'right': (-1, 0),
}[side]
if enable_hinting and (
# Borders smaller than 1 device unit would disappear
# without anti-aliasing.
math.hypot(*context.user_to_device(width, 0)) >= 1 and
math.hypot(*context.user_to_device(0, width)) >= 1):
# Avoid an artefact in the corner joining two solid borders
# of the same color.
context.set_antialias(cairo.ANTIALIAS_NONE)
if style not in ('dotted', 'dashed'):
# Clip on the trapezoid shape
"""
Clip on the trapezoid formed by the border edge (longer)
and the padding edge (shorter).
This is on the top side:
+---------------+ <= border edge ^
\ / |
\ / | top border width
\ / |
+-------+ <= padding edge v
<--> <--> <= left and right border widths
"""
border_start, border_stop = border_edge
padding_start, padding_stop = padding_edge
context.move_to(*border_start)
for point in [border_stop, padding_stop,
padding_start, border_start]:
context.line_to(*point)
context.clip()
if style == 'solid':
# Fill the whole trapezoid
context.paint()
elif style in ('inset', 'outset'):
do_lighten = (side in ('top', 'left')) ^ (style == 'inset')
factor = 1 if do_lighten else -1
context.set_source_rgba(*lighten(color, 0.5 * factor))
context.paint()
elif style in ('groove', 'ridge'):
# TODO: these would look better with more color stops
"""
Divide the width in 2 and stroke lines in different colors
+-------------+
1\ /2
1'\ / 2'
+-------+
"""
do_lighten = (side in ('top', 'left')) ^ (style == 'groove')
factor = 1 if do_lighten else -1
context.set_line_width(width / 2)
(x1, y1), (x2, y2) = border_edge
# from the border edge to the center of the first line
x1, y1 = xy_offset(x1, y1, x_offset, y_offset, width / 4)
x2, y2 = xy_offset(x2, y2, x_offset, y_offset, width / 4)
context.move_to(x1, y1)
context.line_to(x2, y2)
context.set_source_rgba(*lighten(color, 0.5 * factor))
context.stroke()
# Between the centers of both lines. 1/4 + 1/4 = 1/2
x1, y1 = xy_offset(x1, y1, x_offset, y_offset, width / 2)
x2, y2 = xy_offset(x2, y2, x_offset, y_offset, width / 2)
context.move_to(x1, y1)
context.line_to(x2, y2)
context.set_source_rgba(*lighten(color, -0.5 * factor))
context.stroke()
elif style == 'double':
"""
Divide the width in 3 and stroke both outer lines
+---------------+
1\ /2
\ /
1' \ / 2'
+-------+
"""
context.set_line_width(width / 3)
(x1, y1), (x2, y2) = border_edge
# from the border edge to the center of the first line
x1, y1 = xy_offset(x1, y1, x_offset, y_offset, width / 6)
x2, y2 = xy_offset(x2, y2, x_offset, y_offset, width / 6)
context.move_to(x1, y1)
context.line_to(x2, y2)
context.stroke()
# Between the centers of both lines. 1/6 + 1/3 + 1/6 = 2/3
x1, y1 = xy_offset(x1, y1, x_offset, y_offset, 2 * width / 3)
x2, y2 = xy_offset(x2, y2, x_offset, y_offset, 2 * width / 3)
context.move_to(x1, y1)
context.line_to(x2, y2)
context.stroke()
else:
assert style in ('dotted', 'dashed')
(x1, y1), (x2, y2) = border_edge
if style == 'dotted':
# Half-way from the extremities of the border and padding
# edges.
(px1, py1), (px2, py2) = padding_edge
x1 = (x1 + px1) / 2
x2 = (x2 + px2) / 2
y1 = (y1 + py1) / 2
y2 = (y2 + py2) / 2
"""
+---------------+
\ /
1 2
\ /
+-------+
"""
else: # dashed
# From the border edge to the middle:
x1, y1 = xy_offset(x1, y1, x_offset, y_offset, width / 2)
x2, y2 = xy_offset(x2, y2, x_offset, y_offset, width / 2)
"""
+---------------+
\ /
1 \ / 2
\ /
+-------+
"""
length = ((x2 - x1) ** 2 + (y2 - y1) ** 2) ** 0.5
dash = 2 * width
if style == 'dotted':
if context.user_to_device_distance(width, 0)[0] > 3:
# Round so that dash is a divisor of length,
# but not in the dots are too small.
dash = length / round(length / dash)
context.set_line_cap(cairo.LINE_CAP_ROUND)
context.set_dash([0, dash])
else: # dashed
# Round so that 2*dash is a divisor of length
dash = length / (2 * round(length / (2 * dash)))
context.set_dash([dash])
# Stroke along the line in === above, as wide as the border
context.move_to(x1, y1)
context.line_to(x2, y2)
context.set_line_width(width)
context.stroke()
def draw_outlines(context, box, enable_hinting):
width = box.style.outline_width
color = box.style.get_color('outline_color')
style = box.style.outline_style
if box.style.visibility != 'hidden' and width != 0 and color.alpha != 0:
border_box = (box.border_box_x(), box.border_box_y(),
box.border_width(), box.border_height())
outline_box = (border_box[0] - width, border_box[1] - width,
border_box[2] + 2 * width, border_box[3] + 2 * width)
for side, border_edge, padding_edge in zip(
['top', 'right', 'bottom', 'left'],
get_rectangle_edges(*outline_box),
get_rectangle_edges(*border_box),
):
draw_border_segment(context, enable_hinting, style, width, color,
side, border_edge, padding_edge)
if isinstance(box, boxes.ParentBox):
for child in box.children:
if isinstance(child, boxes.Box):
draw_outlines(context, child, enable_hinting)
def draw_collapsed_borders(context, table, enable_hinting):
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: dont bother with empty tables
return
row_positions = [row.position_y for row_group in table.children
for row in row_group.children]
column_positions = 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.
column_positions += [column_positions[-1] + column_widths[-1]]
# 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
skipped_rows = table.skipped_rows
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)
):
_, (_, width, _) = border_list[skipped_rows + y][x]
result = max(result, width)
return result / 2
def add_vertical(x, y):
score, (style, width, color) = vertical_borders[skipped_rows + y][x]
if width == 0 or color.alpha == 0:
return
half_width = width / 2
pos_x = column_positions[x]
pos_y_1 = row_positions[y] - half_max_width(horizontal_borders, [
(y, x - 1), (y, x)], vertical=False)
pos_y_2 = row_positions[y + 1] + half_max_width(horizontal_borders, [
(y + 1, x - 1), (y + 1, x)], vertical=False)
edge_1 = (pos_x - half_width, pos_y_1), (pos_x - half_width, pos_y_2)
edge_2 = (pos_x + half_width, pos_y_1), (pos_x + half_width, pos_y_2)
segments.append((score, style, width, color, 'left', edge_1, edge_2))
def add_horizontal(x, y):
score, (style, width, color) = horizontal_borders[skipped_rows + y][x]
if width == 0 or color.alpha == 0:
return
half_width = width / 2
pos_y = row_positions[y]
# TODO: change signs for rtl when we support rtl tables?
pos_x_1 = column_positions[x] - half_max_width(vertical_borders, [
(y - 1, x), (y, x)])
pos_x_2 = column_positions[x + 1] + half_max_width(vertical_borders, [
(y - 1, x + 1), (y, x + 1)])
edge_1 = (pos_x_1, pos_y - half_width), (pos_x_2, pos_y - half_width)
edge_2 = (pos_x_1, pos_y + half_width), (pos_x_2, pos_y + half_width)
segments.append((score, style, width, color, 'top', edge_1, edge_2))
for x in xrange(grid_width):
add_horizontal(x, 0)
for y in xrange(grid_height):
add_vertical(0, y)
for x in xrange(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:
draw_border_segment(context, enable_hinting, *segment[1:])
def draw_replacedbox(context, box):
"""Draw the given :class:`boxes.ReplacedBox` to a ``cairo.context``."""
if box.style.visibility == 'hidden':
return
x, y = box.content_box_x(), box.content_box_y()
width, height = box.width, box.height
get_pattern, intrinsic_width, intrinsic_height = box.replacement
scale_width = width / intrinsic_width
scale_height = height / intrinsic_height
# Draw nothing for width:0 or height:0
if scale_width != 0 and scale_height != 0:
with stacked(context):
context.translate(x, y)
context.rectangle(0, 0, width, height)
context.clip()
context.scale(scale_width, scale_height)
pattern = get_pattern()
# We might get a shared Pattern that was previously used
# in a repeating background.
pattern.set_extend(cairo.EXTEND_NONE)
pattern.set_filter(IMAGE_RENDERING_TO_FILTER[
box.style.image_rendering])
context.set_source(pattern)
context.paint()
def draw_inline_level(context, page, box, enable_hinting):
if isinstance(box, StackingContext):
stacking_context = box
assert isinstance(stacking_context.box, boxes.InlineBlockBox)
draw_stacking_context(context, stacking_context, enable_hinting)
else:
draw_background(context, box.background, enable_hinting)
draw_border(context, box, enable_hinting)
if isinstance(box, (boxes.InlineBox, boxes.LineBox)):
for child in box.children:
if isinstance(child, boxes.TextBox):
draw_text(context, child, enable_hinting)
else:
draw_inline_level(context, page, child, enable_hinting)
elif isinstance(box, boxes.InlineReplacedBox):
draw_replacedbox(context, box)
else:
assert isinstance(box, boxes.TextBox)
# Should only happen for list markers
draw_text(context, box, enable_hinting)
def draw_text(context, textbox, enable_hinting):
"""Draw ``textbox`` to a ``cairo.Context`` from ``PangoCairo.Context``."""
# Pango crashes with font-size: 0
assert textbox.style.font_size
if textbox.style.visibility == 'hidden':
return
context.move_to(textbox.position_x, textbox.position_y + textbox.baseline)
context.set_source_rgba(*textbox.style.color)
show_first_line(context, textbox.pango_layout, enable_hinting)
values = textbox.style.text_decoration
if 'overline' in values:
draw_text_decoration(context, textbox,
textbox.baseline - 0.15 * textbox.style.font_size,
enable_hinting)
elif 'underline' in values:
draw_text_decoration(context, textbox,
textbox.baseline + 0.15 * textbox.style.font_size,
enable_hinting)
elif 'line-through' in values:
draw_text_decoration(context, textbox, textbox.height * 0.5,
enable_hinting)
def draw_text_decoration(context, textbox, offset_y, enable_hinting):
"""Draw text-decoration of ``textbox`` to a ``cairo.Context``."""
with stacked(context):
if enable_hinting:
context.set_antialias(cairo.ANTIALIAS_NONE)
context.set_source_rgba(*textbox.style.color)
context.set_line_width(1) # TODO: make this proportional to font_size?
context.move_to(textbox.position_x, textbox.position_y + offset_y)
context.rel_line_to(textbox.width, 0)
context.stroke()