From f3b4fa285072f1ea6e3c07d2d6ebaa025487d76f Mon Sep 17 00:00:00 2001 From: Guillaume Ayoub Date: Fri, 26 Feb 2016 13:58:47 +0100 Subject: [PATCH 1/5] Enhance the support of SVG images This commit adds two features: - don't render SVG files twice anymore; and - support SVG images with no intrinsic ratio. --- docs/install.rst | 2 +- setup.py | 2 +- weasyprint/images.py | 74 ++++++++++++++-------- weasyprint/layout/backgrounds.py | 5 +- weasyprint/layout/inlines.py | 101 ++++++++++++++----------------- weasyprint/layout/preferred.py | 3 +- weasyprint/layout/replaced.py | 15 +++-- weasyprint/tests/test_draw.py | 4 +- 8 files changed, 113 insertions(+), 93 deletions(-) diff --git a/docs/install.rst b/docs/install.rst index 2142cc63b..09bf8d945 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -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_ [#]_ diff --git a/setup.py b/setup.py index 4b7f636c0..4f55c3d18 100644 --- a/setup.py +++ b/setup.py @@ -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' diff --git a/weasyprint/images.py b/weasyprint/images.py index 33d45a581..c94614f4e 100644 --- a/weasyprint/images.py +++ b/weasyprint/images.py @@ -13,6 +13,7 @@ from __future__ import division, unicode_literals from io import BytesIO +from xml.etree import ElementTree import math import cairocffi @@ -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) @@ -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. @@ -104,41 +113,54 @@ 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. + + 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: + self.intrinsic_ratio = ( + (viewbox[2] - viewbox[0]) / (viewbox[3] - viewbox[1])) + 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 _render(self): + def _render(self, width=None, height=None): # 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) + output=None, dpi=96, parent_width=width, parent_height=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() + svg = self._render(concrete_width, 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() def get_image_from_uri(cache, url_fetcher, url, forced_mime_type=None): @@ -291,8 +313,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 diff --git a/weasyprint/layout/backgrounds.py b/weasyprint/layout/backgrounds.py index 2a976d866..1238eb2c6 100644 --- a/weasyprint/layout/backgrounds.py +++ b/weasyprint/layout/backgrounds.py @@ -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', @@ -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), diff --git a/weasyprint/layout/inlines.py b/weasyprint/layout/inlines.py index 3f2c50ee8..3afa658b4 100644 --- a/weasyprint/layout/inlines.py +++ b/weasyprint/layout/inlines.py @@ -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 @@ -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): diff --git a/weasyprint/layout/preferred.py b/weasyprint/layout/preferred.py index 1002925b6..903ba1563 100644 --- a/weasyprint/layout/preferred.py +++ b/weasyprint/layout/preferred.py @@ -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) diff --git a/weasyprint/layout/replaced.py b/weasyprint/layout/replaced.py index c227f4f06..bf34ab192 100644 --- a/weasyprint/layout/replaced.py +++ b/weasyprint/layout/replaced.py @@ -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) @@ -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( diff --git a/weasyprint/tests/test_draw.py b/weasyprint/tests/test_draw.py index 0990e3662..e96ae354f 100644 --- a/weasyprint/tests/test_draw.py +++ b/weasyprint/tests/test_draw.py @@ -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, '''