Skip to content

Commit

Permalink
Handle image-orientation
Browse files Browse the repository at this point in the history
  • Loading branch information
liZe committed Sep 23, 2022
1 parent 1d64134 commit a4fc7a1
Show file tree
Hide file tree
Showing 10 changed files with 115 additions and 17 deletions.
4 changes: 1 addition & 3 deletions docs/api_reference.rst
Expand Up @@ -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/
Expand Down
16 changes: 16 additions & 0 deletions tests/draw/test_image.py
Expand Up @@ -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(
'''
<style>@page { size: 10px }</style>
<img style="display: block; image-orientation: 180deg"
src="not-optimized-exif.jpg">
''',
'''
<style>@page { size: 10px }</style>
<img style="display: block" src="not-optimized-exif.jpg">
''',
tolerance=25,
)
37 changes: 33 additions & 4 deletions tests/test_css_validation.py
@@ -1,6 +1,6 @@
"""Test expanders for shorthand properties."""

import math
from math import pi

import pytest
import tinycss2
Expand Down Expand Up @@ -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%)', {
Expand Down Expand Up @@ -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-')):
Expand Down Expand Up @@ -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)
10 changes: 10 additions & 0 deletions 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
Expand Down Expand Up @@ -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')
Expand Down
2 changes: 1 addition & 1 deletion weasyprint/css/properties.py
Expand Up @@ -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, '%')),),
Expand Down
24 changes: 24 additions & 0 deletions weasyprint/css/validation/properties.py
Expand Up @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions weasyprint/formatting_structure/build.py
Expand Up @@ -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)
Expand Down Expand Up @@ -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))
Expand Down
11 changes: 8 additions & 3 deletions weasyprint/html.py
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down
18 changes: 15 additions & 3 deletions weasyprint/images.py
Expand Up @@ -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]
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion weasyprint/layout/background.py
Expand Up @@ -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')

Expand Down

0 comments on commit a4fc7a1

Please sign in to comment.