Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enhance the support of SVG images #299

Merged
merged 5 commits into from
Mar 8, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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