Skip to content

Commit a4fc7a1

Browse files
committed
Handle image-orientation
1 parent 1d64134 commit a4fc7a1

File tree

10 files changed

+115
-17
lines changed

10 files changed

+115
-17
lines changed

docs/api_reference.rst

+1-3
Original file line numberDiff line numberDiff line change
@@ -587,9 +587,7 @@ The ``object-fit`` and ``object-position`` properties are supported.
587587
The ``from-image`` and ``snap`` values of the ``image-resolution`` property are
588588
**not** supported, but the ``resolution`` value is supported.
589589

590-
The ``image-rendering`` property is supported.
591-
592-
The ``image-orientation`` property is **not** supported.
590+
The ``image-rendering`` and ``image-orientation`` properties are supported.
593591

594592
.. _Image Values and Replaced Content Module Level 3: https://www.w3.org/TR/css-images-3/
595593
.. _Image Values and Replaced Content Module Level 4: https://www.w3.org/TR/css-images-4/

tests/draw/test_image.py

+16
Original file line numberDiff line numberDiff line change
@@ -562,3 +562,19 @@ def test_image_exif(assert_same_renderings):
562562
''',
563563
tolerance=25,
564564
)
565+
566+
567+
@assert_no_logs
568+
def test_image_exif_image_orientation(assert_same_renderings):
569+
assert_same_renderings(
570+
'''
571+
<style>@page { size: 10px }</style>
572+
<img style="display: block; image-orientation: 180deg"
573+
src="not-optimized-exif.jpg">
574+
''',
575+
'''
576+
<style>@page { size: 10px }</style>
577+
<img style="display: block" src="not-optimized-exif.jpg">
578+
''',
579+
tolerance=25,
580+
)

tests/test_css_validation.py

+33-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Test expanders for shorthand properties."""
22

3-
import math
3+
from math import pi
44

55
import pytest
66
import tinycss2
@@ -223,8 +223,7 @@ def test_size_invalid(rule):
223223
('transform: none', {'transform': ()}),
224224
('transform: translate(6px) rotate(90deg)', {
225225
'transform': (
226-
('translate', ((6, 'px'), (0, 'px'))),
227-
('rotate', math.pi / 2))}),
226+
('translate', ((6, 'px'), (0, 'px'))), ('rotate', pi / 2))}),
228227
('transform: translate(-4px, 0)', {
229228
'transform': (('translate', ((-4, 'px'), (0, None))),)}),
230229
('transform: translate(6px, 20%)', {
@@ -818,7 +817,6 @@ def test_linear_gradient():
818817
red = (1, 0, 0, 1)
819818
lime = (0, 1, 0, 1)
820819
blue = (0, 0, 1, 1)
821-
pi = math.pi
822820

823821
def gradient(css, direction, colors=(blue,), stop_positions=(None,)):
824822
for repeating, prefix in ((False, ''), (True, 'repeating-')):
@@ -1218,3 +1216,34 @@ def test_text_align(rule, result):
12181216
))
12191217
def test_text_align_invalid(rule, reason):
12201218
assert_invalid(rule, reason)
1219+
1220+
1221+
@assert_no_logs
1222+
@pytest.mark.parametrize('rule, result', (
1223+
('image-orientation: none', {'image_orientation': 'none'}),
1224+
('image-orientation: from-image', {'image_orientation': 'from-image'}),
1225+
('image-orientation: 90deg', {'image_orientation': (pi / 2, False)}),
1226+
('image-orientation: 30deg', {'image_orientation': (pi / 6, False)}),
1227+
('image-orientation: 180deg flip', {'image_orientation': (pi, True)}),
1228+
('image-orientation: 0deg flip', {'image_orientation': (0, True)}),
1229+
('image-orientation: flip 90deg', {'image_orientation': (pi / 2, True)}),
1230+
('image-orientation: flip', {'image_orientation': (0, True)}),
1231+
))
1232+
def test_image_orientation(rule, result):
1233+
assert expand_to_dict(rule) == result
1234+
1235+
@assert_no_logs
1236+
@pytest.mark.parametrize('rule, reason', (
1237+
('image-orientation: none none', 'invalid'),
1238+
('image-orientation: unknown', 'invalid'),
1239+
('image-orientation: none flip', 'invalid'),
1240+
('image-orientation: from-image flip', 'invalid'),
1241+
('image-orientation: 10', 'invalid'),
1242+
('image-orientation: 10 flip', 'invalid'),
1243+
('image-orientation: flip 10', 'invalid'),
1244+
('image-orientation: flip flip', 'invalid'),
1245+
('image-orientation: 90deg flop', 'invalid'),
1246+
('image-orientation: 90deg 180deg', 'invalid'),
1247+
))
1248+
def test_image_orientation_invalid(rule, reason):
1249+
assert_invalid(rule, reason)

weasyprint/css/computed_values.py

+10
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Convert specified property values into computed values."""
22

33
from collections import OrderedDict
4+
from math import pi
45
from urllib.parse import unquote
56

67
from tinycss2.color3 import parse_color
@@ -387,6 +388,15 @@ def background_size(style, name, values):
387388
for value in values)
388389

389390

391+
@register_computer('image-orientation')
392+
def image_orientation(style, name, values):
393+
"""Compute the ``image-orientation`` properties."""
394+
if values in ('none', 'from-image'):
395+
return values
396+
angle, flip = values
397+
return (int(round(angle / pi * 2)) % 4 * 90, flip)
398+
399+
390400
@register_computer('border-top-width')
391401
@register_computer('border-right-width')
392402
@register_computer('border-left-width')

weasyprint/css/properties.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@
126126
# Images 3/4 (CR/WD): https://www.w3.org/TR/css-images-4/
127127
'image_resolution': 1, # dppx
128128
'image_rendering': 'auto',
129-
# https://drafts.csswg.org/css-images-3/
129+
'image_orientation': 'from-image',
130130
'object_fit': 'fill',
131131
'object_position': (('left', Dimension(50, '%'),
132132
'top', Dimension(50, '%')),),

weasyprint/css/validation/properties.py

+24
Original file line numberDiff line numberDiff line change
@@ -1280,6 +1280,30 @@ def image_rendering(keyword):
12801280
return keyword in ('auto', 'crisp-edges', 'pixelated')
12811281

12821282

1283+
@property(unstable=True)
1284+
def image_orientation(tokens):
1285+
"""Validation for ``image-orientation``."""
1286+
keyword = get_single_keyword(tokens)
1287+
if keyword in ('none', 'from-image'):
1288+
return keyword
1289+
angle, flip = None, None
1290+
for token in tokens:
1291+
keyword = get_keyword(token)
1292+
if keyword == 'flip':
1293+
if flip is not None:
1294+
return
1295+
flip = True
1296+
continue
1297+
if angle is None:
1298+
angle = get_angle(token)
1299+
if angle is not None:
1300+
continue
1301+
return
1302+
angle = 0 if angle is None else angle
1303+
flip = False if flip is None else flip
1304+
return (angle, flip)
1305+
1306+
12831307
@property(unstable=True)
12841308
def size(tokens):
12851309
"""``size`` property validation.

weasyprint/formatting_structure/build.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -333,7 +333,8 @@ def marker_to_box(element, state, parent_style, style_for, get_image_from_uri,
333333
else:
334334
if image_type == 'url':
335335
# image may be None here too, in case the image is not available.
336-
image = get_image_from_uri(url=image)
336+
image = get_image_from_uri(
337+
url=image, orientation=style['image_orientation'])
337338
if image is not None:
338339
box = boxes.InlineReplacedBox.anonymous_from(box, image)
339340
children.append(box)
@@ -421,7 +422,8 @@ def add_text(text):
421422
if origin != 'external':
422423
# Embedding internal references is impossible
423424
continue
424-
image = get_image_from_uri(url=uri)
425+
image = get_image_from_uri(
426+
url=uri, orientation=parent_box.style['image_orientation'])
425427
if image is not None:
426428
content_boxes.append(
427429
boxes.InlineReplacedBox.anonymous_from(parent_box, image))

weasyprint/html.py

+8-3
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,8 @@ def handle_img(element, box, get_image_from_uri, base_url):
120120
src = get_url_attribute(element, 'src', base_url)
121121
alt = element.get('alt')
122122
if src:
123-
image = get_image_from_uri(url=src)
123+
image = get_image_from_uri(
124+
url=src, orientation=box.style['image_orientation'])
124125
if image is not None:
125126
return [make_replaced_box(element, box, image)]
126127
else:
@@ -154,7 +155,9 @@ def handle_embed(element, box, get_image_from_uri, base_url):
154155
src = get_url_attribute(element, 'src', base_url)
155156
type_ = element.get('type', '').strip()
156157
if src:
157-
image = get_image_from_uri(url=src, forced_mime_type=type_)
158+
image = get_image_from_uri(
159+
url=src, forced_mime_type=type_,
160+
orientation=box.style['image_orientation'])
158161
if image is not None:
159162
return [make_replaced_box(element, box, image)]
160163
# No fallback.
@@ -171,7 +174,9 @@ def handle_object(element, box, get_image_from_uri, base_url):
171174
data = get_url_attribute(element, 'data', base_url)
172175
type_ = element.get('type', '').strip()
173176
if data:
174-
image = get_image_from_uri(url=data, forced_mime_type=type_)
177+
image = get_image_from_uri(
178+
url=data, forced_mime_type=type_,
179+
orientation=box.style['image_orientation'])
175180
if image is not None:
176181
return [make_replaced_box(element, box, image)]
177182
# The element’s children are the fallback.

weasyprint/images.py

+15-3
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,8 @@ def draw(self, stream, concrete_width, concrete_height, image_rendering):
9292

9393

9494
def get_image_from_uri(cache, url_fetcher, optimize_size, url,
95-
forced_mime_type=None, context=None):
95+
forced_mime_type=None, context=None,
96+
orientation='from-image'):
9697
"""Get an Image instance from an image URI."""
9798
if url in cache:
9899
return cache[url]
@@ -134,8 +135,19 @@ def get_image_from_uri(cache, url_fetcher, optimize_size, url,
134135
else:
135136
# Store image id to enable cache in Stream.add_image
136137
image_id = md5(url.encode()).hexdigest()
137-
if 'exif' in pillow_image.info:
138-
pillow_image = ImageOps.exif_transpose(pillow_image)
138+
if orientation == 'from-image':
139+
if 'exif' in pillow_image.info:
140+
pillow_image = ImageOps.exif_transpose(
141+
pillow_image)
142+
elif orientation != 'none':
143+
angle, flip = orientation
144+
if angle > 0:
145+
rotation = getattr(
146+
Image.Transpose, f'ROTATE_{angle}')
147+
pillow_image = pillow_image.transpose(rotation)
148+
if flip:
149+
pillow_image = pillow_image.transpose(
150+
Image.Transpose.FLIP_LEFT_RIGHT)
139151
image = RasterImage(pillow_image, image_id, optimize_size)
140152

141153
except (URLFetchingError, ImageLoadingError) as exception:

weasyprint/layout/background.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,10 @@ def layout_box_backgrounds(page, box, get_image_from_uri, layout_children=True,
5151
images = []
5252
color = parse_color('transparent')
5353
else:
54+
orientation = style['image_orientation']
5455
images = [
55-
get_image_from_uri(url=value) if type_ == 'url' else value
56+
get_image_from_uri(url=value, orientation=orientation)
57+
if type_ == 'url' else value
5658
for type_, value in style['background_image']]
5759
color = get_color(style, 'background_color')
5860

0 commit comments

Comments
 (0)