Files
turnstone/tests/test_thumbnails.py
T
Patrick Buckley 0171a9dd18 fix(attachments): normalize EXIF orientation so thumbnails and models see upright images
Phone photos store landscape pixels plus an EXIF orientation tag. Browsers honour
the tag for <img>, but Pillow (our thumbnails) and many vision-model image
decoders do not — so the thumbnail rendered rotated AND the model literally
perceived the photo sideways (noticed earlier as model "hallucinations", before
thumbnails made the rotation visible).

Normalize on read, at both surfaces:
- new core/images.normalize_image_orientation: bakes the rotation into the pixels
  and re-encodes (preserving format); images with no / identity orientation pass
  through untouched (pristine original, no per-send cost).
- make_thumbnail applies exif_transpose — after the decompression-bomb pixel gate,
  which now also covers the transpose decode.
- attachment_to_content_part runs image bytes through the normalizer before
  base64, so the primary model and the perception model both get upright pixels.

Because normalization is on read (not at upload), it fixes already-stored uploads
too.
2026-06-16 03:41:51 -07:00

95 lines
3.7 KiB
Python

"""Tests for attachment thumbnail generation (image downscale + pdf first page)."""
from __future__ import annotations
from io import BytesIO
import pytest
from turnstone.core.thumbnails import make_thumbnail
PNG_1x1 = (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\x9cc\xfc\xcf"
b"\xc0\xc0\xc0\x00\x00\x00\x05\x00\x01\xa5\xf6E@\x00\x00\x00\x00IEND\xaeB`\x82"
)
_PNG_MAGIC = b"\x89PNG\r\n\x1a\n"
def _minimal_pdf(text: str = "Hi") -> bytes:
stream = b"BT /F1 24 Tf 20 60 Td (" + text.encode("latin-1") + b") Tj ET"
objs = [
b"<</Type/Catalog/Pages 2 0 R>>",
b"<</Type/Pages/Kids[3 0 R]/Count 1>>",
b"<</Type/Page/Parent 2 0 R/MediaBox[0 0 300 144]"
+ b"/Contents 4 0 R/Resources<</Font<</F1 5 0 R>>>>>>",
b"<</Length %d>>\nstream\n%s\nendstream" % (len(stream), stream),
b"<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>",
]
pdf = b"%PDF-1.4\n"
offsets = []
for i, obj in enumerate(objs, 1):
offsets.append(len(pdf))
pdf += b"%d 0 obj\n%s\nendobj\n" % (i, obj)
xref = len(pdf)
pdf += b"xref\n0 %d\n0000000000 65535 f \n" % (len(objs) + 1)
for off in offsets:
pdf += b"%010d 00000 n \n" % off
pdf += b"trailer\n<</Size %d/Root 1 0 R>>\nstartxref\n%d\n%%%%EOF" % (len(objs) + 1, xref)
return pdf
class TestMakeThumbnail:
def test_image_thumbnail_is_png(self) -> None:
out = make_thumbnail(PNG_1x1, "image")
assert out is not None and out[:8] == _PNG_MAGIC
def test_image_thumbnail_honours_exif_orientation(self) -> None:
pil = pytest.importorskip("PIL.Image")
src = pil.new("RGB", (40, 20), "red") # landscape source
exif = src.getexif()
exif[0x0112] = 6 # "rotate 90° for display" → the thumbnail should be portrait
buf = BytesIO()
src.save(buf, format="JPEG", exif=exif)
out = make_thumbnail(buf.getvalue(), "image")
assert out is not None
thumb = pil.open(BytesIO(out))
assert thumb.height > thumb.width, "thumbnail must reflect the applied EXIF rotation"
def test_pdf_thumbnail_is_png(self) -> None:
out = make_thumbnail(_minimal_pdf(), "pdf")
assert out is not None and out[:8] == _PNG_MAGIC
def test_audio_has_no_thumbnail(self) -> None:
assert make_thumbnail(b"RIFFfake", "audio") is None
def test_garbage_image_returns_none(self) -> None:
assert make_thumbnail(b"not an image", "image") is None
@pytest.mark.filterwarnings("ignore::PIL.Image.DecompressionBombWarning")
def test_oversized_image_rejected(self, monkeypatch) -> None:
# An image past the pixel cap must be rejected WITHOUT decoding it. Use a
# size in the (cap, 2*cap] window — Pillow only *warns* there and would
# decode fully, so this guards the explicit size check, not Pillow's >2x
# raise.
from io import BytesIO
from PIL import Image
monkeypatch.setattr("turnstone.core.thumbnails._MAX_IMAGE_PIXELS", 50)
buf = BytesIO()
Image.new("RGB", (6, 10)).save(buf, format="PNG") # 60 px, in (50, 100]
assert make_thumbnail(buf.getvalue(), "image") is None
def test_at_cap_image_still_renders(self, monkeypatch) -> None:
# Exactly at the cap is allowed (boundary is strictly greater-than).
from io import BytesIO
from PIL import Image
monkeypatch.setattr("turnstone.core.thumbnails._MAX_IMAGE_PIXELS", 64)
buf = BytesIO()
Image.new("RGB", (8, 8)).save(buf, format="PNG") # 64 px == cap
out = make_thumbnail(buf.getvalue(), "image")
assert out is not None and out[:8] == _PNG_MAGIC