gpui: Apply EXIF orientation when decoding static images (#61017)

## Context

Static images decoded by GPUI ignored their EXIF `Orientation` tag, so
JPEGs that rely on it (photos from phones and cameras) rendered rotated
or flipped in image preview, Markdown preview, and other `ImageSource`
consumers. The `image` crate only exposes orientation through
`ImageDecoder::orientation()`, which must be read before the decoder is
consumed; `DynamicImage::from_decoder` does not apply it. The old path
went straight through `image::load_from_memory_with_format`, so the
metadata was never read.

Closes #60844.

Video of manual test below :

[Screencast from 2026-07-15
02-14-41.webm](https://github.com/user-attachments/assets/002b0324-44f8-4ddb-acbd-8c3e070d5248)


## How to Review

**Cargo.toml**
Raises the workspace `image` dependency minimum from `0.25.1` to
`0.25.4`, the first release exposing `orientation()` and
`apply_orientation()`. The lockfile already resolves to `0.25.10`, so no
lock change is required.

**crates/gpui/src/platform.rs**
Adds two crate-private helpers. `decode_static_image_from_decoder` reads
`orientation()`, decodes to a `DynamicImage`, applies the orientation,
then converts RGBA to GPUI's BGRA. `decode_static_image` builds a format
decoder from bytes and delegates to it. `Image::to_image_data` now
routes its static formats through these helpers instead of
`image::load_from_memory_with_format`. Adds
`test_image_to_image_data_applies_exif_orientation`, which decodes a
16x32 fixture stored upside down with EXIF Orientation 3 and asserts the
corner pixels were flipped upright; the test fails if orientation is
dropped.

**crates/gpui/src/elements/img.rs**
Routes `ImageAssetLoader` (Markdown paths, URLs, embedded resources)
through the same helpers, including the static WebP branch. GIF and
animated-WebP frame handling are left unchanged, since animated
orientation is out of scope.

## Self-Review Checklist

- [x] I've reviewed my own diff for quality, security, and reliability
- [ ] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the UI/UX checklist
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- Fixed static images ignoring their EXIF orientation, which caused some
JPEGs to render rotated or flipped in image and Markdown preview.
This commit is contained in:
saberoueslati authored and GitHub committed 2026-07-17 08:29:17 +00:00
1 parent 307b8a706c
commit cf028dbd3e
4 files changed
+61 -46

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 B

+4 -2
View File
@@ -100,7 +100,7 @@ impl Render for ImageShowcase {
.items_center()
.gap_8()
.child(ImageContainer::new(
"Image loaded from a local file",
"Image loaded from a local file with EXIF orientation",
self.local_resource.clone(),
))
.child(ImageContainer::new(
@@ -202,7 +202,9 @@ fn run_example() {
cx.open_window(window_options, |_, cx| {
cx.new(|_| ImageShowcase {
// Relative path to your root project path
local_resource: manifest_dir.join("examples/image/app-icon.png").into(),
local_resource: manifest_dir
.join("examples/image/exif-orientation-rotate-180.jpg")
.into(),
remote_resource: "https://picsum.photos/800/400".into(),
asset_resource: "image/color.svg".into(),
})
+5 -21
View File
@@ -2,14 +2,15 @@ use crate::{
AnyElement, AnyImageCache, App, Asset, AssetLogger, Bounds, DefiniteLength, Element, ElementId,
Entity, GlobalElementId, Hitbox, Image, ImageCache, InspectorElementId, InteractiveElement,
Interactivity, IntoElement, LayoutId, Length, ObjectFit, Pixels, RenderImage, Resource,
SharedString, SharedUri, StyleRefinement, Styled, Task, Window, px,
SharedString, SharedUri, StyleRefinement, Styled, Task, Window, decode_static_image,
decode_static_image_from_decoder, px,
};
use anyhow::Result;
use futures::Future;
use gpui_util::ResultExt;
use image::{
AnimationDecoder, DynamicImage, Frame, ImageError, ImageFormat, Rgba,
AnimationDecoder, ImageError, ImageFormat, Rgba,
codecs::{gif::GifDecoder, webp::WebPDecoder},
};
use scheduler::Instant;
@@ -716,27 +717,10 @@ impl Asset for ImageAssetLoader {
frames
} else {
let mut data = DynamicImage::from_decoder(decoder)?.into_rgba8();
// Convert from RGBA to BGRA.
for pixel in data.chunks_exact_mut(4) {
pixel.swap(0, 2);
}
SmallVec::from_elem(Frame::new(data), 1)
decode_static_image_from_decoder(decoder)?
}
}
_ => {
let mut data =
image::load_from_memory_with_format(&bytes, format)?.into_rgba8();
// Convert from RGBA to BGRA.
for pixel in data.chunks_exact_mut(4) {
pixel.swap(0, 2);
}
SmallVec::from_elem(Frame::new(data), 1)
}
_ => decode_static_image(&bytes, format)?,
};
Ok(Arc::new(RenderImage::new(data)))
+52 -23
View File
@@ -42,15 +42,15 @@ use crate::{
RenderImageParams, RenderSvgParams, Scene, ShapedGlyph, ShapedRun, SharedString, Size,
SvgRenderer, SystemWindowTab, Task, Window, WindowControlArea, hash, point, px, size,
};
use anyhow::Result;
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
use anyhow::bail;
use anyhow::{Context as _, Result};
use async_task::Runnable;
use futures::channel::oneshot;
#[cfg(any(test, feature = "test-support"))]
use image::RgbaImage;
use image::codecs::gif::GifDecoder;
use image::{AnimationDecoder as _, Frame};
use image::{AnimationDecoder as _, DynamicImage, Frame};
use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
use scheduler::Instant;
pub use scheduler::RunnableMeta;
@@ -2368,6 +2368,33 @@ pub struct Image {
pub id: u64,
}
pub(crate) fn decode_static_image(
bytes: &[u8],
format: image::ImageFormat,
) -> Result<SmallVec<[Frame; 1]>> {
let decoder = image::ImageReader::with_format(Cursor::new(bytes), format)
.into_decoder()
.context("creating image decoder")?;
decode_static_image_from_decoder(decoder)
}
pub(crate) fn decode_static_image_from_decoder(
mut decoder: impl image::ImageDecoder,
) -> Result<SmallVec<[Frame; 1]>> {
let orientation = decoder
.orientation()
.context("reading decoder's orientation")?;
let mut image = DynamicImage::from_decoder(decoder).context("decoding image")?;
image.apply_orientation(orientation);
let mut data = image.into_rgba8();
for pixel in data.chunks_exact_mut(4) {
pixel.swap(0, 2);
}
Ok(SmallVec::from_elem(Frame::new(data), 1))
}
impl Hash for Image {
fn hash<H: Hasher>(&self, state: &mut H) {
state.write_u64(self.id);
@@ -2423,20 +2450,6 @@ impl Image {
/// Convert the clipboard image to an `ImageData` object.
pub fn to_image_data(&self, svg_renderer: SvgRenderer) -> Result<Arc<RenderImage>> {
fn frames_for_image(
bytes: &[u8],
format: image::ImageFormat,
) -> Result<SmallVec<[Frame; 1]>> {
let mut data = image::load_from_memory_with_format(bytes, format)?.into_rgba8();
// Convert from RGBA to BGRA.
for pixel in data.chunks_exact_mut(4) {
pixel.swap(0, 2);
}
Ok(SmallVec::from_elem(Frame::new(data), 1))
}
let frames = match self.format {
ImageFormat::Gif => {
let decoder = GifDecoder::new(Cursor::new(&self.bytes))?;
@@ -2463,18 +2476,18 @@ impl Image {
frames
}
ImageFormat::Png => frames_for_image(&self.bytes, image::ImageFormat::Png)?,
ImageFormat::Jpeg => frames_for_image(&self.bytes, image::ImageFormat::Jpeg)?,
ImageFormat::Webp => frames_for_image(&self.bytes, image::ImageFormat::WebP)?,
ImageFormat::Bmp => frames_for_image(&self.bytes, image::ImageFormat::Bmp)?,
ImageFormat::Tiff => frames_for_image(&self.bytes, image::ImageFormat::Tiff)?,
ImageFormat::Ico => frames_for_image(&self.bytes, image::ImageFormat::Ico)?,
ImageFormat::Png => decode_static_image(&self.bytes, image::ImageFormat::Png)?,
ImageFormat::Jpeg => decode_static_image(&self.bytes, image::ImageFormat::Jpeg)?,
ImageFormat::Webp => decode_static_image(&self.bytes, image::ImageFormat::WebP)?,
ImageFormat::Bmp => decode_static_image(&self.bytes, image::ImageFormat::Bmp)?,
ImageFormat::Tiff => decode_static_image(&self.bytes, image::ImageFormat::Tiff)?,
ImageFormat::Ico => decode_static_image(&self.bytes, image::ImageFormat::Ico)?,
ImageFormat::Svg => {
return svg_renderer
.render_single_frame(&self.bytes, 1.0)
.map_err(Into::into);
}
ImageFormat::Pnm => frames_for_image(&self.bytes, image::ImageFormat::Pnm)?,
ImageFormat::Pnm => decode_static_image(&self.bytes, image::ImageFormat::Pnm)?,
};
Ok(Arc::new(RenderImage::new(frames)))
@@ -2559,6 +2572,22 @@ mod image_tests {
use super::*;
use std::sync::Arc;
#[test]
fn test_image_to_image_data_applies_exif_orientation() {
let image = Image::from_bytes(
ImageFormat::Jpeg,
include_bytes!("../examples/image/exif-orientation-rotate-180.jpg").to_vec(),
);
let render_image = image.to_image_data(SvgRenderer::new(Arc::new(()))).unwrap();
assert_eq!(render_image.size(0), size(16.into(), 32.into()));
let bytes = render_image.as_bytes(0).unwrap();
assert_eq!(&bytes[..4], &[255, 255, 255, 255]);
assert_eq!(&bytes[(16 * 32 - 1) * 4..], &[0, 0, 0, 255]);
}
#[test]
fn test_svg_image_to_image_data_converts_to_bgra() {
let image = Image::from_bytes(