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
size of the original image can use the `data-original-dimensions`
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`
tag. As detailed in `server_thumbnail_formats`, both animated and
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:
# This only attempts to read the header, not the full image content
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(
realm_id=attachment.realm_id,
path_id=attachment.path_id,
original_width_px=image.width,
original_height_px=image.height,
original_width_px=width,
original_height_px=height,
frames=image.get_n_pages(),
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:
# We decline to treat as an image a large single-frame image
self.login_user(self.example_user("hamlet"))