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')