The picture is capped at 256 and Gravatar is asked for no more: the profile page already wants 288 #265

Closed
opened 2026-09-17 19:18:06 +00:00 by tiagoagueda · 0 comments
Owner

accounts/avatars.py normalises every picture to exactly 256×256: AVATAR_SIZE = 256,
decoded, straightened, cropped square with ImageOps.fit, re-encoded as PNG. The Gravatar
copy is fetched at the same size — gravatar_url passes ?s=256 from the same constant.

The same reasoning as #264 applies, and here one part of it is not hypothetical.

The profile page is already short of pixels

accounts/profile.html:43 renders {% avatar user "size-24 text-3xl" %}. That is 96 CSS
pixels. On a phone at 3× device pixel ratio the browser wants 288 physical pixels and we
store 256, so the picture on the one page whose subject is the picture is being upscaled
today.

This is not the company logo's situation. There the largest surface is size-14 — 56 CSS
pixels, 168 at 3×, comfortably inside 256 (#264 records that, and that the change there is
for surfaces not yet built). Here the ceiling is already touching.

Decided

Bounded by file size, not by dimensions, exactly as #264 settles it for logos:

  • AVATAR_SIZE stops being the output dimension;
  • MAX_UPLOAD_BYTES (already 5 MB, and already the number #264 proposes aligning logos to)
    stays as the input cap, refusing before decode;
  • an output budget with downscale-and-retry, because re-encoding can grow a file;
  • MAX_PIXELS stays. As on #264: it bounds what Pillow allocates while decoding, not
    what is stored. It is the decompression bomb guard and is untouched by a byte budget.

Ask Gravatar for more. gravatar_url(email, size=AVATAR_SIZE) currently requests
?s=256. Gravatar serves up to 2048, the fetch happens once, server-side, on opt-in, and
d=404 is unaffected. The requested size should follow whatever the new ceiling is rather
than continuing to track a constant that no longer means anything. Cheap, and it is the
difference between a stored picture that can be shown large and one that never could.

The square crop stays. ImageOps.fit rather than contain is deliberate and correct: a
face belongs cropped to the tile, the tile is square everywhere it appears, and the initials
fallback that stands in for a missing picture is a square tile too. Letterboxing a portrait
photograph inside that would look like a mistake. This differs from jobs/logos.py on
purpose — a wordmark must not be cropped, a face should be — and the two should not be
reconciled.

No SVG. #264's sanitiser is for logos. Nobody uploads a vector of their own face, so
extending it here would be attack surface bought for nothing. Explicitly out of scope, and
recorded so the two issues are not merged on the strength of both being about pictures.

Worth noting for later

No document theme or CV renders the person's photograph today — documents/themes/,
resume/ and applications/report_print.html have none. But a European CV commonly carries
one, Postulo ships a europass plugin, and a Europass CV carries one. A passport-style
35 × 45 mm photograph at 300 dpi is about 413 × 531 pixels.

So if that is ever built, 256 was never going to be enough, and by then the originals are
gone — only the processed PNG is kept. That is the same trap #264 describes, and the reason
to lift the ceiling before rather than after.

Existing pictures stay 256² for the same reason: they can only be replaced by uploading
again, or — for a Gravatar copy — by re-fetching, which a raised ?s= makes worthwhile on
its own.

`accounts/avatars.py` normalises every picture to exactly 256×256: `AVATAR_SIZE = 256`, decoded, straightened, cropped square with `ImageOps.fit`, re-encoded as PNG. The Gravatar copy is fetched at the same size — `gravatar_url` passes `?s=256` from the same constant. The same reasoning as #264 applies, and here one part of it is not hypothetical. ## The profile page is already short of pixels `accounts/profile.html:43` renders `{% avatar user "size-24 text-3xl" %}`. That is 96 CSS pixels. On a phone at 3× device pixel ratio the browser wants **288 physical pixels** and we store 256, so the picture on the *one page whose subject is the picture* is being upscaled today. This is not the company logo's situation. There the largest surface is `size-14` — 56 CSS pixels, 168 at 3×, comfortably inside 256 (#264 records that, and that the change there is for surfaces not yet built). Here the ceiling is already touching. ## Decided **Bounded by file size, not by dimensions**, exactly as #264 settles it for logos: - `AVATAR_SIZE` stops being the output dimension; - `MAX_UPLOAD_BYTES` (already 5 MB, and already the number #264 proposes aligning logos to) stays as the input cap, refusing before decode; - an **output budget** with downscale-and-retry, because re-encoding can grow a file; - **`MAX_PIXELS` stays.** As on #264: it bounds what Pillow allocates while decoding, not what is stored. It is the decompression bomb guard and is untouched by a byte budget. **Ask Gravatar for more.** `gravatar_url(email, size=AVATAR_SIZE)` currently requests `?s=256`. Gravatar serves up to 2048, the fetch happens once, server-side, on opt-in, and `d=404` is unaffected. The requested size should follow whatever the new ceiling is rather than continuing to track a constant that no longer means anything. Cheap, and it is the difference between a stored picture that can be shown large and one that never could. **The square crop stays.** `ImageOps.fit` rather than `contain` is deliberate and correct: a face belongs cropped to the tile, the tile is square everywhere it appears, and the initials fallback that stands in for a missing picture is a square tile too. Letterboxing a portrait photograph inside that would look like a mistake. This differs from `jobs/logos.py` on purpose — a wordmark must not be cropped, a face should be — and the two should not be reconciled. **No SVG.** #264's sanitiser is for logos. Nobody uploads a vector of their own face, so extending it here would be attack surface bought for nothing. Explicitly out of scope, and recorded so the two issues are not merged on the strength of both being about pictures. ## Worth noting for later No document theme or CV renders the person's photograph today — `documents/themes/`, `resume/` and `applications/report_print.html` have none. But a European CV commonly carries one, Postulo ships a `europass` plugin, and a Europass CV carries one. A passport-style 35 × 45 mm photograph at 300 dpi is about **413 × 531 pixels**. So if that is ever built, 256 was never going to be enough, and by then the originals are gone — only the processed PNG is kept. That is the same trap #264 describes, and the reason to lift the ceiling before rather than after. Existing pictures stay 256² for the same reason: they can only be replaced by uploading again, or — for a Gravatar copy — by re-fetching, which a raised `?s=` makes worthwhile on its own.
tiagoagueda added this to the 0.5.0 milestone 2026-09-17 19:18:06 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Postulo/postulo#265
No description provided.