1
1
mirror of https://github.com/Kozea/WeasyPrint.git synced 2024-10-05 00:21:15 +03:00
WeasyPrint/weasy/draw.py

470 lines
17 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 converts web documents (HTML, CSS, ...) to PDF.
# Copyright (C) 2011 Simon Sapin
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
Module drawing documents.
"""
from __future__ import division
import contextlib
import cairo
from .formatting_structure import boxes
from .css.values import get_percentage_value
class CairoContext(cairo.Context):
"""A ``cairo.Context`` with a few more helper methods."""
def set_source_colorvalue(self, color, lighten=0):
"""Set the source pattern from a ``cssutils.ColorValue`` object."""
self.set_source_rgba(
color.red / 255. + lighten,
color.green / 255. + lighten,
color.blue / 255. + lighten,
color.alpha)
@contextlib.contextmanager
def stacked(self):
"""Save and restore the context when used with the ``with`` keyword."""
self.save()
try:
yield
finally:
self.restore()
def draw_page(document, page, context):
"""Draw the given PageBox to a Cairo context.
The context should be scaled so that lengths are in CSS pixels.
"""
draw_page_background(document, context, page)
draw_border(context, page)
draw_box(document, context, page.root_box)
def draw_box(document, context, box):
"""Draw a ``box`` on ``context``."""
if box.style.visibility == 'visible':
if has_background(box):
draw_background(document, context, box)
draw_border(context, box)
marker_box = getattr(box, 'outside_list_marker', None)
if marker_box:
draw_box(document, context, marker_box)
if isinstance(box, boxes.TextBox):
draw_text(context, box)
return
if isinstance(box, boxes.ReplacedBox):
draw_replacedbox(context, box)
if isinstance(box, boxes.TableBox):
for child in box.column_groups:
draw_box(document, context, child)
if isinstance(box, boxes.ParentBox):
for child in box.children:
draw_box(document, context, child)
def has_background(box):
"""Return whether the given box has any background."""
return box.style.background_color.alpha > 0 or \
box.style.background_image != 'none'
def draw_page_background(document, context, page):
"""Draw the backgrounds for the page box (from @page style) and for the
page area (from the root element).
If the root element is "html" and has no background, the page areas
background is taken from its "body" child.
In both cases the background position is the same as if it was drawn on
the element.
See http://www.w3.org/TR/CSS21/colors.html#background
"""
# TODO: this one should have its origin at (0, 0), not the border box
# of the page.
# TODO: more tests for this, see
# http://www.w3.org/TR/css3-page/#page-properties
draw_background(document, context, page, clip=False)
if has_background(page.root_box):
draw_background(document, context, page.root_box, clip=False)
elif page.root_box.element_tag.lower() == 'html':
for child in page.root_box.children:
if child.element_tag.lower() == 'body':
# This must be drawn now, before anything on the root element.
draw_background(document, context, child, clip=False)
def draw_background(document, context, box, clip=True):
"""Draw the box background color and image to a ``cairo.Context``."""
if getattr(box, 'background_drawn', False):
return
box.background_drawn = True
if not has_background(box):
return
with context.stacked():
bg_x = box.border_box_x()
bg_y = box.border_box_y()
bg_width = box.border_width()
bg_height = box.border_height()
bg_attachement = box.style.background_attachment
if bg_attachement == 'fixed':
# There should not be any clip yet
x1, y1, x2, y2 = context.clip_extents()
page_width = x2 - x1
page_height = y2 - y1
if clip:
context.rectangle(bg_x, bg_y, bg_width, bg_height)
context.clip()
# Background color
bg_color = box.style.background_color
if bg_color.alpha > 0:
context.set_source_colorvalue(bg_color)
context.paint()
if bg_attachement == 'scroll':
# Change coordinates to make the rest easier.
context.translate(bg_x, bg_y)
else:
assert bg_attachement == 'fixed'
bg_width = page_width
bg_height = page_height
# Background image
bg_image = box.style.background_image
if bg_image == 'none':
return
image = document.get_image_surface_from_uri(bg_image)
if image is None:
return
surface, image_width, image_height = image
bg_position_x, bg_position_y = box.style.background_position
percentage = get_percentage_value(bg_position_x)
if percentage is not None:
bg_position_x = (bg_width - image_width) * percentage / 100.
percentage = get_percentage_value(bg_position_y)
if percentage is not None:
bg_position_y = (bg_height - image_height) * percentage / 100.
context.translate(bg_position_x, bg_position_y)
bg_repeat = box.style.background_repeat
if bg_repeat != 'repeat':
# Get the current clip rectangle
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 in ('no-repeat', 'repeat-x'):
# Limit the drawn area vertically
clip_y1 = 0 # because of the last context.translate()
clip_height = image_height
if bg_repeat in ('no-repeat', '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()
context.set_source_surface(surface)
context.get_source().set_extend(cairo.EXTEND_REPEAT)
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):
"""Draw the box border to a ``cairo.Context``."""
if all(box.style['border_%s_width' % side] == 0
for side in ['top', 'right', 'bottom', 'left']):
# No border, return early.
return
for side, x_offset, y_offset, border_edge, padding_edge in zip(
['top', 'right', 'bottom', 'left'],
[0, -1, 0, 1],
[1, 0, -1, 0],
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 = box.style['border_%s_width' % side]
if width == 0:
continue
color = box.style['border_%s_color' % side]
if color.alpha == 0:
continue
style = box.style['border_%s_style' % side]
with context.stacked():
context.set_source_colorvalue(color)
# 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'):
lighten = (side in ('top', 'left')) ^ (style == 'inset')
factor = 1 if lighten else -1
context.set_source_colorvalue(color, lighten=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'
+-------+
"""
lighten = (side in ('top', 'left')) ^ (style == 'groove')
factor = 1 if 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_colorvalue(color, lighten=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_colorvalue(color, lighten=-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_replacedbox(context, box):
"""Draw the given :class:`boxes.ReplacedBox` to a ``cairo.context``."""
x, y = box.padding_box_x(), box.padding_box_y()
width, height = box.width, box.height
surface, intrinsic_width, intrinsic_height = box.replacement
with context.stacked():
context.translate(x, y)
context.rectangle(0, 0, width, height)
context.clip()
scale_width = width / intrinsic_width
scale_height = height / intrinsic_height
context.scale(scale_width, scale_height)
context.set_source_surface(surface)
context.paint()
def draw_text(context, textbox):
"""Draw ``textbox`` to a ``cairo.Context`` from ``PangoCairo.Context``."""
# Pango crashes with font-size: 0
assert textbox.style.font_size
context.move_to(textbox.position_x, textbox.position_y + textbox.baseline)
context.set_source_colorvalue(textbox.style.color)
textbox.show_line(context)
values = textbox.style.text_decoration
for value in values:
if value == 'overline':
draw_overline(context, textbox)
elif value == 'underline':
draw_underline(context, textbox)
elif value == 'line-through':
draw_line_through(context, textbox)
def draw_overline(context, textbox):
"""Draw overline of ``textbox`` to a ``cairo.Context``."""
font_size = textbox.style.font_size
position_y = textbox.baseline + textbox.position_y - (font_size * 0.15)
draw_text_decoration(context, position_y, textbox)
def draw_underline(context, textbox):
"""Draw underline of ``textbox`` to a ``cairo.Context``."""
font_size = textbox.style.font_size
position_y = textbox.baseline + textbox.position_y + (font_size * 0.15)
draw_text_decoration(context, position_y, textbox)
def draw_line_through(context, textbox):
"""Draw line-through of ``textbox`` to a ``cairo.Context``."""
position_y = textbox.position_y + (textbox.height * 0.5)
draw_text_decoration(context, position_y, textbox)
def draw_text_decoration(context, position_y, textbox):
"""Draw text-decoration of ``textbox`` to a ``cairo.Context``."""
with context.stacked():
context.set_source_colorvalue(textbox.style.color)
context.set_line_width(1) # TODO: make this proportional to font_size?
context.move_to(textbox.position_x, position_y)
context.rel_line_to(textbox.width, 0)
context.stroke()