Skip to content

Commit

Permalink
Merge pull request #299 from Kozea/svg
Browse files Browse the repository at this point in the history
Enhance the support of SVG images
  • Loading branch information
liZe committed Mar 8, 2016
2 parents 6d8417d + 777ca82 commit 98b78a1
Show file tree
Hide file tree
Showing 8 changed files with 122 additions and 99 deletions.
2 changes: 1 addition & 1 deletion docs/install.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ WeasyPrint |version| depends on:
* cairocffi_ ≥ 0.3
* tinycss_ = 0.3
* cssselect_ ≥ 0.6
* CairoSVG_ ≥ 0.5
* CairoSVG_ ≥ 1.0.20
* Pyphen_ ≥ 0.8
* Optional: GDK-PixBuf_ [#]_

Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
'html5lib>=0.999',
'tinycss==0.3',
'cssselect>=0.6',
'CairoSVG>=0.4.1',
'CairoSVG>=1.0.20',
'cffi>=0.6',
'cairocffi>=0.5',
'Pyphen>=0.8'
Expand Down
89 changes: 57 additions & 32 deletions weasyprint/images.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from __future__ import division, unicode_literals

from io import BytesIO
from xml.etree import ElementTree
import math

import cairocffi
Expand Down Expand Up @@ -67,7 +68,7 @@ def __init__(self, image_surface):
self._intrinsic_width / self._intrinsic_height
if self._intrinsic_height != 0 else float('inf'))

def get_intrinsic_size(self, image_resolution):
def get_intrinsic_size(self, image_resolution, _font_size):
# Raster images are affected by the 'image-resolution' property.
return (self._intrinsic_width / image_resolution,
self._intrinsic_height / image_resolution)
Expand Down Expand Up @@ -96,6 +97,14 @@ def device_units_per_user_units(self):
return scale / 0.75


class FakeSurface(object):
"""Fake CairoSVG surface used to get SVG attributes."""
context_height = 0
context_width = 0
font_size = 12
dpi = 96


class SVGImage(object):
def __init__(self, svg_data, base_url):
# Don’t pass data URIs to CairoSVG.
Expand All @@ -104,41 +113,57 @@ def __init__(self, svg_data, base_url):
base_url if not base_url.lower().startswith('data:') else None)
self._svg_data = svg_data

# TODO: find a way of not doing twice the whole rendering.
try:
svg = self._render()
self._tree = ElementTree.fromstring(self._svg_data)
except Exception as e:
raise ImageLoadingError.from_exception(e)
# TODO: support SVG images with none or only one of intrinsic
# width, height and ratio.
if not (svg.width > 0 and svg.height > 0):
raise ImageLoadingError(
'SVG images without an intrinsic size are not supported.')
self._intrinsic_width = svg.width
self._intrinsic_height = svg.height
self.intrinsic_ratio = self._intrinsic_width / self._intrinsic_height

def get_intrinsic_size(self, _image_resolution):
# Vector images are affected by the 'image-resolution' property.
return self._intrinsic_width, self._intrinsic_height

def _render(self):
# Draw to a cairo surface but do not write to a file.
# This is a CairoSVG surface, not a cairo surface.
return ScaledSVGSurface(
cairosvg.parser.Tree(
bytestring=self._svg_data, url=self._base_url),
output=None, dpi=96)
def get_intrinsic_size(self, _image_resolution, font_size):
# Vector images may be affected by the font size.
fake_surface = FakeSurface()
fake_surface.font_size = font_size
# Percentages don't provide an intrinsic size, we transform percentages
# into 0 using a (0, 0) context size:
# http://www.w3.org/TR/SVG/coords.html#IntrinsicSizing
self._width = cairosvg.surface.size(
fake_surface, self._tree.get('width'))
self._height = cairosvg.surface.size(
fake_surface, self._tree.get('height'))
_, _, viewbox = cairosvg.surface.node_format(fake_surface, self._tree)
self._intrinsic_width = self._width or None
self._intrinsic_height = self._height or None
self.intrinsic_ratio = None
if viewbox:
if self._width and self._height:
self.intrinsic_ratio = self._width / self._height
else:
if viewbox[2] and viewbox[3]:
self.intrinsic_ratio = viewbox[2] / viewbox[3]
if self._width:
self._intrinsic_height = (
self._width / self.intrinsic_ratio)
elif self._height:
self._intrinsic_width = (
self._height * self.intrinsic_ratio)
elif self._width and self._height:
self.intrinsic_ratio = self._width / self._height
return self._intrinsic_width, self._intrinsic_height

def draw(self, context, concrete_width, concrete_height, _image_rendering):
# Do not re-use the rendered Surface object,
# but regenerate it as needed.
# If a surface for a SVG image is still alive by the time we call
# show_page(), cairo will rasterize the image instead writing vectors.
svg = self._render()
context.scale(concrete_width / svg.width, concrete_height / svg.height)
context.set_source_surface(svg.cairo)
context.paint()
try:
svg = ScaledSVGSurface(
cairosvg.parser.Tree(
bytestring=self._svg_data, url=self._base_url),
output=None, dpi=96, parent_width=concrete_width,
parent_height=concrete_height)
if svg.width and svg.height:
context.scale(
concrete_width / svg.width, concrete_height / svg.height)
context.set_source_surface(svg.cairo)
context.paint()
except Exception as e:
LOGGER.warning(
'Failed to draw an SVG image at %s : %s', self._base_url, e)


def get_image_from_uri(cache, url_fetcher, url, forced_mime_type=None):
Expand Down Expand Up @@ -291,8 +316,8 @@ def __init__(self, color_stops, repeating):
#: bool
self.repeating = repeating

def get_intrinsic_size(self, _image_resolution):
# Raster images are affected by the 'image-resolution' property.
def get_intrinsic_size(self, _image_resolution, _font_size):
# Gradients are not affected by image resolution, parent or font size.
return None, None

intrinsic_ratio = None
Expand Down
5 changes: 3 additions & 2 deletions weasyprint/layout/backgrounds.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ def layout_background_layer(box, page, resolution, image, size, clip, repeat,
assert clip == 'content-box', clip
clipped_boxes = [box.rounded_content_box()]

if image is None or 0 in image.get_intrinsic_size(1):
if image is None or 0 in image.get_intrinsic_size(1, 1):
return BackgroundLayer(
image=None, unbounded=(box is page), painting_area=painting_area,
size='unused', position='unused', repeat='unused',
Expand All @@ -172,7 +172,8 @@ def layout_background_layer(box, page, resolution, image, size, clip, repeat,
positioning_width, positioning_height, image.intrinsic_ratio)
else:
size_width, size_height = size
iwidth, iheight = image.get_intrinsic_size(resolution)
iwidth, iheight = image.get_intrinsic_size(
resolution, box.style.font_size)
image_width, image_height = replaced.default_image_sizing(
iwidth, iheight, image.intrinsic_ratio,
percentage(size_width, positioning_width),
Expand Down
101 changes: 47 additions & 54 deletions weasyprint/layout/inlines.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,47 +248,42 @@ def replaced_box_width(box, device_size):
"""
Compute and set the used width for replaced boxes (inline- or block-level)
"""
# http://www.w3.org/TR/CSS21/visudet.html#inline-replaced-width
intrinsic_width, intrinsic_height = box.replacement.get_intrinsic_size(
box.style.image_resolution)
# TODO: update this when we have replaced elements that do not
# always have an intrinsic width. (See commented code below.)
assert intrinsic_width is not None
assert intrinsic_height is not None
box.style.image_resolution, box.style.font_size)

# This algorithm simply follows the different points of the specification:
# http://www.w3.org/TR/CSS21/visudet.html#inline-replaced-width
if box.height == 'auto' and box.width == 'auto':
if intrinsic_width is not None:
# Point #1
box.width = intrinsic_width
elif box.replacement.intrinsic_ratio is not None:
if intrinsic_height is not None:
# Point #2 first part
box.width = intrinsic_height * box.replacement.intrinsic_ratio
else:
# Point #3
# " It is suggested that, if the containing block's width does
# not itself depend on the replaced element's width, then the
# used value of 'width' is calculated from the constraint
# equation used for block-level, non-replaced elements in
# normal flow. "
# Whaaaaat? Let's not do this and use a value that may work
# well at least with inline blocks.
box.width = (
box.style.font_size * box.replacement.intrinsic_ratio)

if box.width == 'auto':
if box.height == 'auto':
if box.replacement.intrinsic_ratio is not None:
# Point #2 second part
box.width = box.height * box.replacement.intrinsic_ratio
elif intrinsic_width is not None:
# Point #4
box.width = intrinsic_width
else:
intrinsic_ratio = intrinsic_width / intrinsic_height
box.width = box.height * intrinsic_ratio

# Untested code for when we do not always have an intrinsic width.
# if box.height == 'auto' and box.width == 'auto':
# if intrinsic_width is not None:
# box.width = intrinsic_width
# elif intrinsic_height is not None and intrinsic_ratio is not None:
# box.width = intrinsic_ratio * intrinsic_height
# elif box.height != 'auto' and intrinsic_ratio is not None:
# box.width = intrinsic_ratio * box.height
# elif intrinsic_ratio is not None:
# pass
# # TODO: Intrinsic ratio only: undefined in CSS 2.1.
# # " It is suggested that, if the containing block's width does not
# # itself depend on the replaced element's width, then the used
# # value of 'width' is calculated from the constraint equation
# # used for block-level, non-replaced elements in normal flow. "

# # Still no value
# if box.width == 'auto':
# if intrinsic_width is not None:
# box.width = intrinsic_width
# else:
# # Then the used value of 'width' becomes 300px. If 300px is too
# # wide to fit the device, UAs should use the width of the largest
# # rectangle that has a 2:1 ratio and fits the device instead.
# device_width, _device_height = device_size
# box.width = min(300, device_width)
# Point #5
device_width, _device_height = device_size
box.width = min(300, device_width)


@handle_min_max_height
Expand All @@ -298,35 +293,33 @@ def replaced_box_height(box, device_size):
"""
# http://www.w3.org/TR/CSS21/visudet.html#inline-replaced-height
intrinsic_width, intrinsic_height = box.replacement.get_intrinsic_size(
box.style.image_resolution)
# TODO: update this when we have replaced elements that do not
# always have intrinsic dimensions. (See commented code below.)
assert intrinsic_width is not None
assert intrinsic_height is not None
box.style.image_resolution, box.style.font_size)

if intrinsic_height == 0:
# Results in box.height == 0 if used, whatever the used width
# or intrinsic width.
intrinsic_ratio = float('inf')
else:
elif intrinsic_width and intrinsic_height:
intrinsic_ratio = intrinsic_width / intrinsic_height
else:
intrinsic_ratio = None

# Test 'auto' on the computed width, not the used width
if box.style.height == 'auto' and box.style.width == 'auto':
box.height = intrinsic_height
elif box.style.height == 'auto':
elif box.style.height == 'auto' and intrinsic_ratio:
box.height = box.width / intrinsic_ratio

# Untested code for when we do not always have intrinsic dimensions.
# if box.style.height == 'auto' and box.style.width == 'auto':
# if intrinsic_height is not None:
# box.height = intrinsic_height
# elif intrinsic_ratio is not None and box.style.height == 'auto':
# box.height = box.width / intrinsic_ratio
# elif box.style.height == 'auto' and intrinsic_height is not None:
# box.height = intrinsic_height
# elif box.style.height == 'auto':
# device_width, _device_height = device_size
# box.height = min(150, device_width / 2)
if (box.style.height == 'auto' and box.style.width == 'auto' and
intrinsic_height is not None):
box.height = intrinsic_height
elif intrinsic_ratio is not None and box.style.height == 'auto':
box.height = box.width / intrinsic_ratio
elif box.style.height == 'auto' and intrinsic_height is not None:
box.height = intrinsic_height
elif box.style.height == 'auto':
device_width, _device_height = device_size
box.height = min(150, device_width / 2)


def inline_replaced_box_layout(box, device_size):
Expand Down
3 changes: 2 additions & 1 deletion weasyprint/layout/preferred.py
Original file line number Diff line number Diff line change
Expand Up @@ -554,7 +554,8 @@ def replaced_min_content_width(box, outer=True):
assert height.unit == 'px'
height = height.value
image = box.replacement
iwidth, iheight = image.get_intrinsic_size(box.style.image_resolution)
iwidth, iheight = image.get_intrinsic_size(
box.style.image_resolution, box.style.font_size)
width, _ = default_image_sizing(
iwidth, iheight, image.intrinsic_ratio, 'auto', height,
default_width=300, default_height=150)
Expand Down
15 changes: 10 additions & 5 deletions weasyprint/layout/replaced.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ def image_marker_layout(box):
"""
image = box.replacement
one_em = box.style.font_size
iwidth, iheight = image.get_intrinsic_size(box.style.image_resolution)
iwidth, iheight = image.get_intrinsic_size(
box.style.image_resolution, one_em)
box.width, box.height = default_image_sizing(
iwidth, iheight, image.intrinsic_ratio, box.width, box.height,
default_width=one_em, default_height=one_em)
Expand Down Expand Up @@ -57,10 +58,14 @@ def default_image_sizing(intrinsic_width, intrinsic_height, intrinsic_ratio,
else default_width
), specified_height
else:
return (intrinsic_width if intrinsic_width is not None
else default_width,
intrinsic_height if intrinsic_height is not None
else default_height)
if intrinsic_width is not None or intrinsic_height is not None:
return default_image_sizing(
intrinsic_width, intrinsic_height, intrinsic_ratio,
intrinsic_width, intrinsic_height, default_width,
default_height)
else:
return contain_constraint_image_sizing(
default_width, default_height, intrinsic_ratio)


def contain_constraint_image_sizing(
Expand Down
4 changes: 1 addition & 3 deletions weasyprint/tests/test_draw.py
Original file line number Diff line number Diff line change
Expand Up @@ -1328,11 +1328,9 @@ def test_images():
alt="Hello, world!">'''),
]
])
assert len(logs) == 2
assert len(logs) == 1
assert 'WARNING: Failed to load image' in logs[0]
assert 'inexistent2.png' in logs[0]
assert 'WARNING: Failed to load image at data:image/svg+xml' in logs[1]
assert 'intrinsic size' in logs[1]

assert_pixels('image_0x1', 8, 8, no_image, '''
<style>
Expand Down

0 comments on commit 98b78a1

Please sign in to comment.