From a4fc7a11a90727195fd3edd781f2265aaf386a66 Mon Sep 17 00:00:00 2001 From: Guillaume Ayoub Date: Fri, 23 Sep 2022 15:44:16 +0200 Subject: [PATCH] Handle image-orientation --- docs/api_reference.rst | 4 +-- tests/draw/test_image.py | 16 ++++++++++ tests/test_css_validation.py | 37 +++++++++++++++++++++--- weasyprint/css/computed_values.py | 10 +++++++ weasyprint/css/properties.py | 2 +- weasyprint/css/validation/properties.py | 24 +++++++++++++++ weasyprint/formatting_structure/build.py | 6 ++-- weasyprint/html.py | 11 +++++-- weasyprint/images.py | 18 ++++++++++-- weasyprint/layout/background.py | 4 ++- 10 files changed, 115 insertions(+), 17 deletions(-) diff --git a/docs/api_reference.rst b/docs/api_reference.rst index cc32cac766..8846a67807 100644 --- a/docs/api_reference.rst +++ b/docs/api_reference.rst @@ -587,9 +587,7 @@ The ``object-fit`` and ``object-position`` properties are supported. The ``from-image`` and ``snap`` values of the ``image-resolution`` property are **not** supported, but the ``resolution`` value is supported. -The ``image-rendering`` property is supported. - -The ``image-orientation`` property is **not** supported. +The ``image-rendering`` and ``image-orientation`` properties are supported. .. _Image Values and Replaced Content Module Level 3: https://www.w3.org/TR/css-images-3/ .. _Image Values and Replaced Content Module Level 4: https://www.w3.org/TR/css-images-4/ diff --git a/tests/draw/test_image.py b/tests/draw/test_image.py index 661039c595..bab8020184 100644 --- a/tests/draw/test_image.py +++ b/tests/draw/test_image.py @@ -562,3 +562,19 @@ def test_image_exif(assert_same_renderings): ''', tolerance=25, ) + + +@assert_no_logs +def test_image_exif_image_orientation(assert_same_renderings): + assert_same_renderings( + ''' + + + ''', + ''' + + + ''', + tolerance=25, + ) diff --git a/tests/test_css_validation.py b/tests/test_css_validation.py index f407c5b50a..8cadcc1158 100644 --- a/tests/test_css_validation.py +++ b/tests/test_css_validation.py @@ -1,6 +1,6 @@ """Test expanders for shorthand properties.""" -import math +from math import pi import pytest import tinycss2 @@ -223,8 +223,7 @@ def test_size_invalid(rule): ('transform: none', {'transform': ()}), ('transform: translate(6px) rotate(90deg)', { 'transform': ( - ('translate', ((6, 'px'), (0, 'px'))), - ('rotate', math.pi / 2))}), + ('translate', ((6, 'px'), (0, 'px'))), ('rotate', pi / 2))}), ('transform: translate(-4px, 0)', { 'transform': (('translate', ((-4, 'px'), (0, None))),)}), ('transform: translate(6px, 20%)', { @@ -818,7 +817,6 @@ def test_linear_gradient(): red = (1, 0, 0, 1) lime = (0, 1, 0, 1) blue = (0, 0, 1, 1) - pi = math.pi def gradient(css, direction, colors=(blue,), stop_positions=(None,)): for repeating, prefix in ((False, ''), (True, 'repeating-')): @@ -1218,3 +1216,34 @@ def test_text_align(rule, result): )) def test_text_align_invalid(rule, reason): assert_invalid(rule, reason) + + +@assert_no_logs +@pytest.mark.parametrize('rule, result', ( + ('image-orientation: none', {'image_orientation': 'none'}), + ('image-orientation: from-image', {'image_orientation': 'from-image'}), + ('image-orientation: 90deg', {'image_orientation': (pi / 2, False)}), + ('image-orientation: 30deg', {'image_orientation': (pi / 6, False)}), + ('image-orientation: 180deg flip', {'image_orientation': (pi, True)}), + ('image-orientation: 0deg flip', {'image_orientation': (0, True)}), + ('image-orientation: flip 90deg', {'image_orientation': (pi / 2, True)}), + ('image-orientation: flip', {'image_orientation': (0, True)}), +)) +def test_image_orientation(rule, result): + assert expand_to_dict(rule) == result + +@assert_no_logs +@pytest.mark.parametrize('rule, reason', ( + ('image-orientation: none none', 'invalid'), + ('image-orientation: unknown', 'invalid'), + ('image-orientation: none flip', 'invalid'), + ('image-orientation: from-image flip', 'invalid'), + ('image-orientation: 10', 'invalid'), + ('image-orientation: 10 flip', 'invalid'), + ('image-orientation: flip 10', 'invalid'), + ('image-orientation: flip flip', 'invalid'), + ('image-orientation: 90deg flop', 'invalid'), + ('image-orientation: 90deg 180deg', 'invalid'), +)) +def test_image_orientation_invalid(rule, reason): + assert_invalid(rule, reason) diff --git a/weasyprint/css/computed_values.py b/weasyprint/css/computed_values.py index d289852bbd..81fc873e1c 100644 --- a/weasyprint/css/computed_values.py +++ b/weasyprint/css/computed_values.py @@ -1,6 +1,7 @@ """Convert specified property values into computed values.""" from collections import OrderedDict +from math import pi from urllib.parse import unquote from tinycss2.color3 import parse_color @@ -387,6 +388,15 @@ def background_size(style, name, values): for value in values) +@register_computer('image-orientation') +def image_orientation(style, name, values): + """Compute the ``image-orientation`` properties.""" + if values in ('none', 'from-image'): + return values + angle, flip = values + return (int(round(angle / pi * 2)) % 4 * 90, flip) + + @register_computer('border-top-width') @register_computer('border-right-width') @register_computer('border-left-width') diff --git a/weasyprint/css/properties.py b/weasyprint/css/properties.py index 72f18e3523..561a46509f 100644 --- a/weasyprint/css/properties.py +++ b/weasyprint/css/properties.py @@ -126,7 +126,7 @@ # Images 3/4 (CR/WD): https://www.w3.org/TR/css-images-4/ 'image_resolution': 1, # dppx 'image_rendering': 'auto', - # https://drafts.csswg.org/css-images-3/ + 'image_orientation': 'from-image', 'object_fit': 'fill', 'object_position': (('left', Dimension(50, '%'), 'top', Dimension(50, '%')),), diff --git a/weasyprint/css/validation/properties.py b/weasyprint/css/validation/properties.py index d71d3c450c..d2b555fa99 100644 --- a/weasyprint/css/validation/properties.py +++ b/weasyprint/css/validation/properties.py @@ -1280,6 +1280,30 @@ def image_rendering(keyword): return keyword in ('auto', 'crisp-edges', 'pixelated') +@property(unstable=True) +def image_orientation(tokens): + """Validation for ``image-orientation``.""" + keyword = get_single_keyword(tokens) + if keyword in ('none', 'from-image'): + return keyword + angle, flip = None, None + for token in tokens: + keyword = get_keyword(token) + if keyword == 'flip': + if flip is not None: + return + flip = True + continue + if angle is None: + angle = get_angle(token) + if angle is not None: + continue + return + angle = 0 if angle is None else angle + flip = False if flip is None else flip + return (angle, flip) + + @property(unstable=True) def size(tokens): """``size`` property validation. diff --git a/weasyprint/formatting_structure/build.py b/weasyprint/formatting_structure/build.py index 99741315de..1272878614 100644 --- a/weasyprint/formatting_structure/build.py +++ b/weasyprint/formatting_structure/build.py @@ -333,7 +333,8 @@ def marker_to_box(element, state, parent_style, style_for, get_image_from_uri, else: if image_type == 'url': # image may be None here too, in case the image is not available. - image = get_image_from_uri(url=image) + image = get_image_from_uri( + url=image, orientation=style['image_orientation']) if image is not None: box = boxes.InlineReplacedBox.anonymous_from(box, image) children.append(box) @@ -421,7 +422,8 @@ def add_text(text): if origin != 'external': # Embedding internal references is impossible continue - image = get_image_from_uri(url=uri) + image = get_image_from_uri( + url=uri, orientation=parent_box.style['image_orientation']) if image is not None: content_boxes.append( boxes.InlineReplacedBox.anonymous_from(parent_box, image)) diff --git a/weasyprint/html.py b/weasyprint/html.py index 731e07f5e8..016ae52d73 100644 --- a/weasyprint/html.py +++ b/weasyprint/html.py @@ -120,7 +120,8 @@ def handle_img(element, box, get_image_from_uri, base_url): src = get_url_attribute(element, 'src', base_url) alt = element.get('alt') if src: - image = get_image_from_uri(url=src) + image = get_image_from_uri( + url=src, orientation=box.style['image_orientation']) if image is not None: return [make_replaced_box(element, box, image)] else: @@ -154,7 +155,9 @@ def handle_embed(element, box, get_image_from_uri, base_url): src = get_url_attribute(element, 'src', base_url) type_ = element.get('type', '').strip() if src: - image = get_image_from_uri(url=src, forced_mime_type=type_) + image = get_image_from_uri( + url=src, forced_mime_type=type_, + orientation=box.style['image_orientation']) if image is not None: return [make_replaced_box(element, box, image)] # No fallback. @@ -171,7 +174,9 @@ def handle_object(element, box, get_image_from_uri, base_url): data = get_url_attribute(element, 'data', base_url) type_ = element.get('type', '').strip() if data: - image = get_image_from_uri(url=data, forced_mime_type=type_) + image = get_image_from_uri( + url=data, forced_mime_type=type_, + orientation=box.style['image_orientation']) if image is not None: return [make_replaced_box(element, box, image)] # The element’s children are the fallback. diff --git a/weasyprint/images.py b/weasyprint/images.py index 0d98af7f29..290b52851a 100644 --- a/weasyprint/images.py +++ b/weasyprint/images.py @@ -92,7 +92,8 @@ def draw(self, stream, concrete_width, concrete_height, image_rendering): def get_image_from_uri(cache, url_fetcher, optimize_size, url, - forced_mime_type=None, context=None): + forced_mime_type=None, context=None, + orientation='from-image'): """Get an Image instance from an image URI.""" if url in cache: return cache[url] @@ -134,8 +135,19 @@ def get_image_from_uri(cache, url_fetcher, optimize_size, url, else: # Store image id to enable cache in Stream.add_image image_id = md5(url.encode()).hexdigest() - if 'exif' in pillow_image.info: - pillow_image = ImageOps.exif_transpose(pillow_image) + if orientation == 'from-image': + if 'exif' in pillow_image.info: + pillow_image = ImageOps.exif_transpose( + pillow_image) + elif orientation != 'none': + angle, flip = orientation + if angle > 0: + rotation = getattr( + Image.Transpose, f'ROTATE_{angle}') + pillow_image = pillow_image.transpose(rotation) + if flip: + pillow_image = pillow_image.transpose( + Image.Transpose.FLIP_LEFT_RIGHT) image = RasterImage(pillow_image, image_id, optimize_size) except (URLFetchingError, ImageLoadingError) as exception: diff --git a/weasyprint/layout/background.py b/weasyprint/layout/background.py index 75bd7a5820..282b460937 100644 --- a/weasyprint/layout/background.py +++ b/weasyprint/layout/background.py @@ -51,8 +51,10 @@ def layout_box_backgrounds(page, box, get_image_from_uri, layout_children=True, images = [] color = parse_color('transparent') else: + orientation = style['image_orientation'] images = [ - get_image_from_uri(url=value) if type_ == 'url' else value + get_image_from_uri(url=value, orientation=orientation) + if type_ == 'url' else value for type_, value in style['background_image']] color = get_color(style, 'background_color')