thumbnail: Store the post-orientation-transformation dimensions.

Modern browsers respect the EXIF orientation information of images,
applying rotation and/or mirroring as specified in those tags.  The
the `width="..."` and `height="..."` tags are to size the image
_after_ applying those orientation transformations.

The `.width` and `.height` properties of libvips' images are _before_
any transformations are applied.  Since we intend to use these to hint
to rendering clients the size that the image should be _rendered at_,
change to storing (and providing to clients) the dimensions of the
rendered image, not the stored bytes.
This commit is contained in:
Alex Vandiver 2024-07-24 15:01:20 +00:00 committed by Tim Abbott
parent e7ac62aad7
commit e4a8304f57
4 changed files with 66 additions and 3 deletions

View File

@ -86,7 +86,8 @@ previews:
available. Clients that would like to size the lightbox based on the available. Clients that would like to size the lightbox based on the
size of the original image can use the `data-original-dimensions` size of the original image can use the `data-original-dimensions`
attribute, which encodes the dimensions of the original image as attribute, which encodes the dimensions of the original image as
`{width}x{height}`, to do so. `{width}x{height}`, to do so. These dimensions are for the image as
rendered, _after_ any EXIF rotation and mirroring has been applied.
- Animated images will have a `data-animated` attribute on the `img` - Animated images will have a `data-animated` attribute on the `img`
tag. As detailed in `server_thumbnail_formats`, both animated and tag. As detailed in `server_thumbnail_formats`, both animated and
still images are available for clients to use, depending on their still images are available for clients to use, depending on their

View File

@ -295,11 +295,23 @@ def maybe_thumbnail(attachment: AbstractAttachment, content: bytes) -> ImageAtta
try: try:
# This only attempts to read the header, not the full image content # This only attempts to read the header, not the full image content
with libvips_check_image(content) as image: with libvips_check_image(content) as image:
# "original_width_px" and "original_height_px" here are
# _as rendered_, after applying the orientation
# information which the image may contain.
if (
"orientation" in image.get_fields()
and image.get("orientation") >= 5
and image.get("orientation") <= 8
):
(width, height) = (image.height, image.width)
else:
(width, height) = (image.width, image.height)
image_row = ImageAttachment.objects.create( image_row = ImageAttachment.objects.create(
realm_id=attachment.realm_id, realm_id=attachment.realm_id,
path_id=attachment.path_id, path_id=attachment.path_id,
original_width_px=image.width, original_width_px=width,
original_height_px=image.height, original_height_px=height,
frames=image.get_n_pages(), frames=image.get_n_pages(),
thumbnail_metadata=[], thumbnail_metadata=[],
) )

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

@ -398,6 +398,56 @@ class TestStoreThumbnail(ZulipTestCase):
), ),
) )
def test_image_orientation(self) -> None:
self.login_user(self.example_user("hamlet"))
with (
self.thumbnail_formats(ThumbnailFormat("webp", 100, 75, animated=False)),
self.captureOnCommitCallbacks(execute=True),
):
with get_test_image_file("orientation.jpg") as image_file:
response = self.assert_json_success(
self.client_post("/json/user_uploads", {"file": image_file})
)
path_id = re.sub(r"/user_uploads/", "", response["url"])
self.assertEqual(Attachment.objects.filter(path_id=path_id).count(), 1)
image_attachment = ImageAttachment.objects.get(path_id=path_id)
# The bytes in this image are 100 wide, and 600 tall --
# however, it has EXIF orientation information which says
# to rotate it 270 degrees counter-clockwise.
self.assertEqual(image_attachment.original_height_px, 100)
self.assertEqual(image_attachment.original_width_px, 600)
# The worker triggers when we exit this block and call the pending callbacks
image_attachment = ImageAttachment.objects.get(path_id=path_id)
self.assert_length(image_attachment.thumbnail_metadata, 1)
generated_thumbnail = StoredThumbnailFormat(**image_attachment.thumbnail_metadata[0])
# The uploaded original content is technically "tall", not "wide", with a 270 CCW rotation set.
with BytesIO() as fh:
save_attachment_contents(path_id, fh)
thumbnailed_bytes = fh.getvalue()
with pyvips.Image.new_from_buffer(thumbnailed_bytes, "") as thumbnailed_image:
self.assertEqual(thumbnailed_image.get("vips-loader"), "jpegload_buffer")
self.assertEqual(thumbnailed_image.width, 100)
self.assertEqual(thumbnailed_image.height, 600)
self.assertEqual(thumbnailed_image.get("orientation"), 8) # 270 CCW rotation
# The generated thumbnail should be wide, not tall, with the default orientation
self.assertEqual(str(generated_thumbnail), "100x75.webp")
self.assertEqual(generated_thumbnail.width, 100)
self.assertEqual(generated_thumbnail.height, 17)
with BytesIO() as fh:
save_attachment_contents(f"thumbnail/{path_id}/100x75.webp", fh)
thumbnailed_bytes = fh.getvalue()
with pyvips.Image.new_from_buffer(thumbnailed_bytes, "") as thumbnailed_image:
self.assertEqual(thumbnailed_image.get("vips-loader"), "webpload_buffer")
self.assertEqual(thumbnailed_image.width, 100)
self.assertEqual(thumbnailed_image.height, 17)
self.assertEqual(thumbnailed_image.get("orientation"), 1)
def test_big_upload(self) -> None: def test_big_upload(self) -> None:
# We decline to treat as an image a large single-frame image # We decline to treat as an image a large single-frame image
self.login_user(self.example_user("hamlet")) self.login_user(self.example_user("hamlet"))