From 26d040d11666bf4635e7d1d284e2238281234219 Mon Sep 17 00:00:00 2001 From: Afonso Coutinho Date: Wed, 3 Jun 2026 06:23:04 +0100 Subject: [PATCH] fix: gallery records raw instead of display dimensions for EXIF-rotated photos (#1667) --- routes/gallery_helpers.py | 13 ++++- tests/test_gallery_exif_orientation.py | 71 ++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 tests/test_gallery_exif_orientation.py diff --git a/routes/gallery_helpers.py b/routes/gallery_helpers.py index b36befe..5cab627 100644 --- a/routes/gallery_helpers.py +++ b/routes/gallery_helpers.py @@ -32,10 +32,21 @@ def _extract_exif(content: bytes) -> dict: from PIL import Image from io import BytesIO 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["height"] = img.height - exif = img._getexif() if hasattr(img, '_getexif') else None if not exif: return result diff --git a/tests/test_gallery_exif_orientation.py b/tests/test_gallery_exif_orientation.py new file mode 100644 index 0000000..aafebd9 --- /dev/null +++ b/tests/test_gallery_exif_orientation.py @@ -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)