fix: gallery records raw instead of display dimensions for EXIF-rotated photos (#1667)

This commit is contained in:
Afonso Coutinho
2026-06-03 06:23:04 +01:00
committed by GitHub
parent b396252af6
commit 26d040d116
2 changed files with 83 additions and 1 deletions

View File

@@ -32,10 +32,21 @@ def _extract_exif(content: bytes) -> dict:
from PIL import Image from PIL import Image
from io import BytesIO from io import BytesIO
img = Image.open(BytesIO(content)) img = Image.open(BytesIO(content))
# Read the raw EXIF before any transpose: exif_transpose strips the
# orientation tag and with it the parsed EXIF view.
exif = img._getexif() if hasattr(img, '_getexif') else None
# Record DISPLAY dimensions (EXIF-rotated), matching upload_handler.
# A phone photo with Orientation 6/8 is stored landscape but shown
# portrait, so the raw width/height swap the aspect ratio.
try:
from PIL import ImageOps
img = ImageOps.exif_transpose(img) or img
except Exception:
pass
result["width"] = img.width result["width"] = img.width
result["height"] = img.height result["height"] = img.height
exif = img._getexif() if hasattr(img, '_getexif') else None
if not exif: if not exif:
return result return result

View File

@@ -0,0 +1,71 @@
"""Gallery EXIF extraction must report display (EXIF-rotated) dimensions.
A phone photo with EXIF Orientation 6 or 8 is stored e.g. 400x300 but
displayed 300x400. _extract_exif read img.width/img.height from the raw
buffer, so the gallery recorded the wrong aspect ratio for rotated photos
while upload_handler (which applies ImageOps.exif_transpose) got it right.
"""
import importlib
import sys
import types
from io import BytesIO
from unittest.mock import MagicMock
import pytest
pytest.importorskip("PIL")
from PIL import Image
@pytest.fixture
def extract_exif(monkeypatch):
"""Import routes.gallery_helpers under a core.database stub.
_extract_exif never touches the DB, but the module imports GalleryImage
at import time and the conftest sqlalchemy stubs make the real
core.database unimportable in isolation.
"""
class _DBStub(types.ModuleType):
def __getattr__(self, name):
return MagicMock()
monkeypatch.setitem(sys.modules, "core.database", _DBStub("core.database"))
monkeypatch.delitem(sys.modules, "routes.gallery_helpers", raising=False)
mod = importlib.import_module("routes.gallery_helpers")
return mod._extract_exif
def _jpeg(width, height, orientation=None, make=None):
img = Image.new("RGB", (width, height), "blue")
exif = Image.Exif()
if orientation is not None:
exif[0x0112] = orientation # Orientation
if make is not None:
exif[0x010F] = make # Make
buf = BytesIO()
img.save(buf, format="JPEG", exif=exif)
return buf.getvalue()
def test_orientation_6_reports_display_dimensions(extract_exif):
res = extract_exif(_jpeg(400, 300, orientation=6))
assert (res["width"], res["height"]) == (300, 400)
def test_orientation_8_reports_display_dimensions(extract_exif):
res = extract_exif(_jpeg(400, 300, orientation=8))
assert (res["width"], res["height"]) == (300, 400)
def test_no_orientation_keeps_raw_dimensions(extract_exif):
res = extract_exif(_jpeg(400, 300))
assert (res["width"], res["height"]) == (400, 300)
def test_camera_fields_survive_the_transpose(extract_exif):
# exif_transpose strips the EXIF view, so tags must be read before it
res = extract_exif(_jpeg(400, 300, orientation=6, make="TestMake"))
assert res["camera_make"] == "TestMake"
assert (res["width"], res["height"]) == (300, 400)