diff --git a/website/admin/award_admin.py b/website/admin/award_admin.py index cda960b4..962c8465 100644 --- a/website/admin/award_admin.py +++ b/website/admin/award_admin.py @@ -8,10 +8,27 @@ from image_cropping import ImageCroppingMixin from website.models import Award from website.admin.admin_site import ml_admin_site +from website.utils.fileutils import pad_image_to_square from sortedm2m_filter_horizontal_widget.forms import SortedFilteredSelectMultiple class AwardAdminForm(forms.ModelForm): + # When set, a non-square badge upload is padded to a centered square on save + # instead of being cropped to one (#1410). See AwardAdmin.save_model and + # website.utils.fileutils.pad_image_to_square for the why/how; the live + # admin preview is driven by pad_to_square.js / pad_to_square.css. + pad_badge_to_square = forms.BooleanField( + required=False, + initial=True, + label="Pad badge to a square (don't crop)", + help_text=( + "If the uploaded badge isn't square, add blank margins to make it " + "square instead of cropping it — keeping the whole image, " + "centered. Margins are transparent for PNG/WebP and white for JPEG. " + "Uncheck to crop to a square with the tool above instead." + ), + ) + class Meta: model = Award fields = '__all__' @@ -35,6 +52,12 @@ def clean(self): class AwardAdmin(ImageCroppingMixin, admin.ModelAdmin): form = AwardAdminForm + class Media: + # Drives the "pad to square" toggle: hides the cropper and shows an + # object-fit:contain preview when padding is selected (#1410). + js = ("website/js/pad_to_square.js",) + css = {"all": ("website/css/pad_to_square.css",)} + # get_recipient_names / get_project_names are methods on the Award model; # their column headers come from each method's short_description. list_display = ('title', 'get_display_thumbnail', 'organization', 'date', @@ -55,6 +78,27 @@ class AwardAdmin(ImageCroppingMixin, admin.ModelAdmin): def get_queryset(self, request): return super().get_queryset(request).prefetch_related('recipients', 'projects') + def save_model(self, request, obj, form, change): + """Optionally pad a freshly uploaded badge to a centered square instead + of cropping it (#1410). + + The badge is cropped to a 1:1 square on the public Awards page. When + "pad to square" is checked and a new, non-square badge was uploaded, we + pad it here with white/transparent margins (see ``pad_image_to_square``) + and store a full-image crop box, so the square comes from padding rather + than from chopping off content. When unchecked — or when the badge + wasn't changed — nothing happens and the interactive cropper above + behaves exactly as before. + """ + if (form.cleaned_data.get('pad_badge_to_square') + and 'badge' in form.changed_data and obj.badge): + result = pad_image_to_square(obj.badge) + if result is not None: + content, box = result + obj.badge.save(content.name, content, save=False) + obj.badge_cropping = box + super().save_model(request, obj, form, change) + def get_fieldsets(self, request, obj=None): # Built at request time so reverse() can resolve the Publications admin URL. publications_url = reverse('admin:website_publication_changelist') @@ -84,7 +128,8 @@ def get_fieldsets(self, request, obj=None): 'fields': ['url', 'description'], }), ('Display', { - 'fields': ['badge', 'badge_cropping', 'badge_alt_text'], + 'fields': ['badge', 'pad_badge_to_square', 'badge_cropping', + 'badge_alt_text'], 'description': 'Optional. On the Awards page, faculty honors show a medal icon, ' 'student awards show the recipient’s photo, and project awards ' 'show the project thumbnail. Upload a badge/logo here to override ' diff --git a/website/static/website/css/pad_to_square.css b/website/static/website/css/pad_to_square.css new file mode 100644 index 00000000..b8bc5138 --- /dev/null +++ b/website/static/website/css/pad_to_square.css @@ -0,0 +1,57 @@ +/* "Pad to square" preview for the Award badge admin (#1410). Pairs with + pad_to_square.js and website.utils.fileutils.pad_image_to_square. + + The interactive cropper and the padded preview are mutually exclusive: the + cropper matters only when cropping, the preview only when padding. Toggling + the `pad-mode` class on the
swaps which one is shown — no JS timing + dependency on ml_cropper.js building the cropper. */ +.pad-preview { display: none; margin: 6px 0 10px 160px; } +form.pad-mode .pad-preview { display: block; } +form.pad-mode .ml-cropper { display: none; } + +.pad-preview__label { + display: block; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.03em; + color: #555; + margin-bottom: 4px; +} + +.pad-preview__box { + width: 245px; + height: 245px; + border: 1px solid #ccc; + border-radius: 4px; + background: #fff; /* white margins (JPEG / opaque) by default */ + overflow: hidden; +} + +/* Checkerboard reveals transparent margins (PNG/WebP/GIF), the way image + editors show transparency. */ +.pad-preview__box--checker { + background-color: #fff; + background-image: + linear-gradient(45deg, #e6e6e6 25%, transparent 25%), + linear-gradient(-45deg, #e6e6e6 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, #e6e6e6 75%), + linear-gradient(-45deg, transparent 75%, #e6e6e6 75%); + background-size: 16px 16px; + background-position: 0 0, 0 8px, 8px -8px, -8px 0; +} + +/* The honest part: object-fit:contain letterboxes the image inside the square + box exactly the way the server pads it (centered, equal margins). */ +.pad-preview__img { + width: 100%; + height: 100%; + object-fit: contain; + display: block; +} + +.pad-preview__note { + font-size: 11px; + color: #777; + margin: 4px 0 0; + max-width: 245px; +} diff --git a/website/static/website/js/pad_to_square.js b/website/static/website/js/pad_to_square.js new file mode 100644 index 00000000..2960025a --- /dev/null +++ b/website/static/website/js/pad_to_square.js @@ -0,0 +1,115 @@ +/** + * pad_to_square.js — "pad to square" preview for the Award badge admin (#1410). + * + * The badge is cropped to a 1:1 square on the public Awards page (via + * image_cropping + Cropper.js). For a non-square logo, cropping chops off + * content, so AwardAdminForm offers a "Pad badge to a square (don't crop)" + * checkbox: when checked, the server pads the upload with white/transparent + * margins (see website.utils.fileutils.pad_image_to_square) instead of cropping. + * + * This script keeps the admin honest about that choice WITHOUT re-implementing + * the padding in the browser (which would mean a fiddly canvas + file-swap + * dance). Instead: + * - When "pad" is checked, the interactive cropper is hidden (there is + * nothing to crop) and we show a faithful preview using CSS + * `object-fit: contain` — which letterboxes the image inside a square box + * exactly the way the server pads it (centered, equal margins). + * - When unchecked, the cropper returns and behaves as before. + * + * Hiding the cropper is done purely by toggling a `pad-mode` class on the form + * (see pad_to_square.css), so there's no ordering dependency on ml_cropper.js + * building its widget first — the CSS rule applies whenever that widget exists. + * + * Vanilla JS only (no jQuery / build step), per project conventions. Scoped to + * the badge field by name; to reuse elsewhere, parameterize the two constants. + */ +(function () { + "use strict"; + + var CHECKBOX_NAME = "pad_badge_to_square"; + var FILE_NAME = "badge"; + + /** + * PNG/WebP/GIF can carry alpha, so their padded margins are transparent; + * JPEG can't, so its margins are white. We sniff the picked file's MIME type + * (or fall back to the existing image's URL/extension) to mirror that in the + * preview's background. + */ + function isTransparentFormat(file, fallbackUrl) { + var s = ((file && file.type) || fallbackUrl || "").toLowerCase(); + return /png|webp|gif/.test(s); + } + + function setup(checkbox) { + var form = checkbox.closest("form") || document; + var fileInput = form.querySelector('[name="' + FILE_NAME + '"]'); + if (!fileInput) return; + + // Build the padded-square preview once, just after the file input's row. + var fileRow = + fileInput.closest(".form-row, .field-" + FILE_NAME) || fileInput.parentNode; + var preview = document.createElement("div"); + preview.className = "pad-preview"; + preview.innerHTML = + 'Preview (padded to a square)' + + '
' + + '

'; + fileRow.parentNode.insertBefore(preview, fileRow.nextSibling); + + var box = preview.querySelector(".pad-preview__box"); + var imgEl = preview.querySelector(".pad-preview__img"); + var note = preview.querySelector(".pad-preview__note"); + var objectUrl = null; + var originalUrl = fileInput.getAttribute("data-original-url"); + + function showImage(src, transparent) { + if (!src) { + imgEl.removeAttribute("src"); + note.textContent = "Upload a badge to preview it."; + return; + } + imgEl.src = src; + box.classList.toggle("pad-preview__box--checker", transparent); + note.textContent = transparent + ? "Transparent margins are added (checkerboard = transparent)." + : "White margins are added."; + } + + // Seed from an existing badge on the edit page, if any. + showImage(originalUrl || null, isTransparentFormat(null, originalUrl)); + + fileInput.addEventListener("change", function () { + var file = fileInput.files && fileInput.files[0]; + if (objectUrl) { + URL.revokeObjectURL(objectUrl); + objectUrl = null; + } + if (file) { + objectUrl = URL.createObjectURL(file); + showImage(objectUrl, isTransparentFormat(file, file.name)); + } else { + showImage(originalUrl || null, isTransparentFormat(null, originalUrl)); + } + }); + + function sync() { + form.classList.toggle("pad-mode", checkbox.checked); + } + checkbox.addEventListener("change", sync); + sync(); + } + + function init() { + document + .querySelectorAll( + 'input[type="checkbox"][name="' + CHECKBOX_NAME + '"]' + ) + .forEach(setup); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(); diff --git a/website/tests/test_pad_image_to_square.py b/website/tests/test_pad_image_to_square.py new file mode 100644 index 00000000..3a9d975e --- /dev/null +++ b/website/tests/test_pad_image_to_square.py @@ -0,0 +1,173 @@ +""" +Tests for auto-padding a non-square upload to a centered square (#1410). + +The Award badge is cropped to a 1:1 square on the public Awards page (via +``image_cropping`` + Cropper.js). For a non-square logo that crops away content, +the admin instead offers a "pad to square" option that keeps the whole image and +adds blank margins. ``website.utils.fileutils.pad_image_to_square`` does that +transform server-side; ``AwardAdmin.save_model`` wires it to the checkbox. + +Two styles, per the repo convention: + * unit tests of ``pad_image_to_square`` (pure Pillow logic, no DB); + * an integration test of ``AwardAdmin.save_model`` (the real save path). +""" + +import io +import shutil +import tempfile + +from PIL import Image +from django.conf import settings +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import SimpleTestCase, override_settings + +from website.tests.base import DatabaseTestCase +from website.utils.fileutils import pad_image_to_square + + +def _upload(name, size, color, mode="RGB", fmt="PNG"): + """Build a SimpleUploadedFile holding a solid-color image of ``size``.""" + img = Image.new(mode, size, color) + buf = io.BytesIO() + img.save(buf, format=fmt) + return SimpleUploadedFile(name, buf.getvalue(), + content_type="image/" + fmt.lower()) + + +# --- pad_image_to_square (the transform) ----------------------------------- + + +class PadImageToSquareTests(SimpleTestCase): + """Unit tests for the Pillow padding helper. No DB; runs in ms.""" + + def _open(self, content): + return Image.open(io.BytesIO(content.read())) + + def test_already_square_returns_none(self): + """A square upload is left untouched so its original bytes survive.""" + result = pad_image_to_square( + _upload("a.png", (150, 150), (10, 20, 30, 255), "RGBA", "PNG")) + self.assertIsNone(result) + + def test_landscape_jpeg_gets_white_square(self): + result = pad_image_to_square( + _upload("logo.jpg", (200, 100), (0, 0, 0), "RGB", "JPEG")) + self.assertIsNotNone(result) + content, box = result + self.assertEqual(box, "0,0,200,200") + img = self._open(content) + self.assertEqual(img.size, (200, 200)) + self.assertEqual(img.format, "JPEG") + # Top margin is white; the centered band is the black original. + self.assertGreater(min(img.convert("RGB").getpixel((100, 5))), 240) + self.assertLess(max(img.convert("RGB").getpixel((100, 100))), 15) + + def test_portrait_png_gets_transparent_square(self): + result = pad_image_to_square( + _upload("logo.png", (100, 200), (255, 0, 0, 255), "RGBA", "PNG")) + content, box = result + self.assertEqual(box, "0,0,200,200") + img = self._open(content) + self.assertEqual(img.size, (200, 200)) + self.assertEqual(img.mode, "RGBA") + # Left margin transparent; center is the opaque red original. + self.assertEqual(img.getpixel((5, 100))[3], 0) + self.assertEqual(img.getpixel((100, 100)), (255, 0, 0, 255)) + + def test_webp_stays_webp_and_transparent(self): + """WebP keeps its format with transparent margins (saved lossless so a + lossless source isn't degraded by the padding re-encode).""" + result = pad_image_to_square( + _upload("logo.webp", (100, 200), (12, 34, 56, 255), "RGBA", "WEBP")) + content, box = result + self.assertEqual(box, "0,0,200,200") + img = self._open(content) + self.assertEqual(img.format, "WEBP") + self.assertEqual(img.size, (200, 200)) + self.assertEqual(img.convert("RGBA").getpixel((5, 100))[3], 0) # margin + + def test_content_is_centered_with_equal_margins(self): + # 200x100 -> 200x200: content occupies rows 50..150, margins above/below. + result = pad_image_to_square( + _upload("c.png", (200, 100), (0, 255, 0, 255), "RGBA", "PNG")) + content, _ = result + img = self._open(content) + self.assertEqual(img.getpixel((100, 25))[3], 0) # top margin + self.assertEqual(img.getpixel((100, 175))[3], 0) # bottom margin + self.assertEqual(img.getpixel((100, 100))[3], 255) # centered content + + +# --- AwardAdmin.save_model wiring ------------------------------------------ + + +@override_settings(MEDIA_ROOT=tempfile.mkdtemp()) +class AwardBadgePaddingAdminTests(DatabaseTestCase): + """The checkbox path: a non-square badge is padded square on save, and a + full-image crop box is stored so the public render doesn't crop it.""" + + @classmethod + def tearDownClass(cls): + shutil.rmtree(settings.MEDIA_ROOT, ignore_errors=True) + super().tearDownClass() + + def _save_award(self, pad): + from unittest.mock import MagicMock + from website.admin.admin_site import ml_admin_site + from website.admin.award_admin import AwardAdminForm + from website.models import Award + + person = self.make_person() + form = AwardAdminForm( + data={ + "title": "Test Award", + "date": "2024-01-01", + "organization": "ACM", + "award_type": "Faculty Honor", + "recipients": [person.pk], + "projects": [], + "url": "", + "description": "", + "badge_cropping": "", + "badge_alt_text": "", + "pad_badge_to_square": "on" if pad else "", + }, + files={"badge": _upload("badge.png", (200, 100), + (0, 0, 255, 255), "RGBA", "PNG")}, + ) + self.assertTrue(form.is_valid(), form.errors) + obj = form.save(commit=False) + admin_obj = ml_admin_site._registry[Award] + admin_obj.save_model(MagicMock(), obj, form, change=False) + return obj + + def test_save_model_pads_badge_when_checked(self): + obj = self._save_award(pad=True) + with Image.open(obj.badge.path) as img: + self.assertEqual(img.size[0], img.size[1]) # square + self.assertEqual(obj.badge_cropping, "0,0,200,200") + + def test_save_model_leaves_badge_when_unchecked(self): + obj = self._save_award(pad=False) + with Image.open(obj.badge.path) as img: + self.assertEqual(img.size, (200, 100)) # unchanged, still cropped later + + +# --- The add page actually renders the toggle + its assets ----------------- + + +class AwardAddPageRendersToggleTests(DatabaseTestCase): + """End-to-end-ish check that the change form exposes the checkbox and pulls + in pad_to_square.js/.css, so the wiring is verified without a browser.""" + + def test_add_page_has_checkbox_and_assets(self): + from django.contrib.auth.models import User + from django.urls import reverse + + User.objects.create_superuser("admin", "a@b.co", "pw") + self.client.force_login(User.objects.get(username="admin")) + resp = self.client.get(reverse("admin:website_award_add")) + self.assertEqual(resp.status_code, 200) + html = resp.content.decode() + self.assertIn('name="pad_badge_to_square"', html) + self.assertIn("pad_to_square.js", html) + self.assertIn("pad_to_square.css", html) diff --git a/website/utils/fileutils.py b/website/utils/fileutils.py index ff5f935d..d55fbf14 100644 --- a/website/utils/fileutils.py +++ b/website/utils/fileutils.py @@ -52,10 +52,125 @@ def is_image(filename): "jpeg": "image/jpeg", "png": "image/png", "gif": "image/gif"} - + filename = filename.lower() return filename[filename.rfind(".") + 1:] in ext2conttype + +def pad_image_to_square(image_file): + """Pad a non-square image to a centered square, returning a Django + ``ContentFile`` of the padded image and the matching full-image crop box. + + Returns ``(ContentFile, "0,0,side,side")`` when padding was applied, or + ``None`` when the image is already square or can't be read (so the caller + leaves the original upload untouched). + + Why this exists (#1410) + ----------------------- + Several admin image fields (Award.badge today; Person/Sponsor are similar) + are cropped to a fixed 1:1 square via ``image_cropping`` + Cropper.js. For a + logo or emblem that isn't square, cropping to a square *chops off content*. + Editors usually want the opposite: keep the whole image and add blank + margins -- exactly what they'd otherwise do by hand in an external editor + before uploading. This does that padding for them, keeping the original + pixels centered (equal margins on the two short sides). + + Why server-side (and not in the browser) + ---------------------------------------- + The cropper's instant preview is client-side, so the obvious worry is that + padding on the server would make the preview lie. We deliberately keep the + *transform* here anyway: it's a ~15-line Pillow operation (Pillow is already + a dependency) versus a much fiddlier client-side canvas + file-replacement + dance, and it works regardless of the browser. The admin keeps an honest + preview cheaply with CSS ``object-fit: contain`` (see pad_to_square.js): + when "pad to square" is checked the interactive cropper is hidden -- there + is nothing to crop -- and a letterboxed preview shows what will be saved. + The caller then stores a full-image crop box so the existing + ``crop_corners`` render path is a no-op on the already-square file. + + Background fill follows the format: PNG/WebP (alpha-capable) get + *transparent* margins so the badge blends onto any page background; JPEG + (no alpha) gets *white* margins. GIF/other are normalized to transparent + PNG. Output format/extension otherwise match the upload so the upload + validator and on-disk naming are unaffected. + + A note on lossy formats: padding always requires re-encoding -- you can't + "insert" margin pixels into a compressed JPEG/WebP stream (it's stored as + DCT/compressed blocks, not addressable pixels), so Pillow decodes the whole + image to a bitmap, we paste it centered, and the *entire* image is encoded + again. We don't reuse the source's original compression scheme: JPEG is + re-encoded at a fixed quality=92 (Pillow's quality="keep" only works on an + unmodified image, and pasting onto a larger canvas modifies it), which costs + one extra, visually negligible generation of compression on the flat + logos/emblems badges usually are. WebP is saved lossless so a lossless + source isn't silently degraded; PNG/GIF are lossless anyway. Already-square + uploads return ``None`` and are never re-encoded at all. + """ + from io import BytesIO + from PIL import Image, ImageOps + from django.core.files.base import ContentFile + + name = getattr(image_file, "name", "") or "image" + try: + image_file.seek(0) + img = Image.open(image_file) + fmt = (img.format or "").upper() # captured before transpose drops it + # Respect EXIF orientation so a phone photo isn't padded sideways; this + # also matches how the browser renders the in the CSS preview. + img = ImageOps.exif_transpose(img) + except Exception: + # Unreadable/corrupt image: let the normal upload path (and the upload + # validator) handle it rather than crashing the save. + _logger.warning("pad_image_to_square: could not read %s; skipping", name) + return None + + width, height = img.size + if width == height: + return None # already square -> keep the original bytes untouched + + side = max(width, height) + offset = ((side - width) // 2, (side - height) // 2) + orig_ext = os.path.splitext(name)[1].lstrip(".").lower() + + if fmt in ("JPEG", "JPG"): + # No alpha channel: center the image on a white square. + base = img.convert("RGB") + canvas = Image.new("RGB", (side, side), (255, 255, 255)) + canvas.paste(base, offset) + save_fmt = "JPEG" + ext = orig_ext if orig_ext in ("jpg", "jpeg") else "jpg" + elif fmt in ("PNG", "WEBP"): + # Alpha-capable: transparent margins so the badge blends on any bg. + base = img.convert("RGBA") + canvas = Image.new("RGBA", (side, side), (255, 255, 255, 0)) + canvas.paste(base, offset, base) # use alpha as its own paste mask + save_fmt = fmt + ext = orig_ext if orig_ext in ("png", "webp") else fmt.lower() + else: + # GIF or anything else: normalize to a transparent PNG. + base = img.convert("RGBA") + canvas = Image.new("RGBA", (side, side), (255, 255, 255, 0)) + canvas.paste(base, offset, base) + save_fmt = "PNG" + ext = "png" + + buffer = BytesIO() + save_kwargs = {"format": save_fmt} + if save_fmt == "JPEG": + # Lossy + block-based: padding re-encodes the whole image (see docstring). + # Fixed quality=92 -- the source's own quantization tables can't be + # reused once we paste onto a larger canvas. + save_kwargs["quality"] = 92 + elif save_fmt == "WEBP": + # WebP defaults to lossy (quality 80); force lossless so a lossless + # source isn't silently degraded by the re-encode that padding requires. + save_kwargs["lossless"] = True + canvas.save(buffer, **save_kwargs) + + base_name = os.path.splitext(os.path.basename(name))[0] or "badge" + content = ContentFile(buffer.getvalue(), name="{}.{}".format(base_name, ext)) + return content, "0,0,{0},{0}".format(side) + # The Star Wars LEGO figures that seed a Person's default headshot / easter-egg # image live under media/images/StarWarsFiguresFullSquare//. 'Rebels' is # the canonical set used for easter eggs (see Person.easter_egg).