Files
intotheeast-com/docs/solutions/ui-bugs/grav-cropresize-vs-cropzoom-for-cover-images.md
T
m038andClaude Opus 4.8 838f237ef5 docs(solutions): capture Grav cropResize-vs-cropZoom cover-image gotcha
New ui-bugs learning: cropResize fits-inside (returns a source-aspect
sliver), cropZoom crops-to-fill. Using cropResize for a cover/banner
strip hands the browser a portrait sliver that object-fit:cover then
upscales into a blur. Documents the fix, the empirical op comparison,
and prevention (verify Medium op dimensions; guard retina upscaling;
regression-test composition, not just the URL).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDS6t8wcpbwKvvrxykVQ5K
2026-07-07 23:49:58 +02:00

121 lines
6.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: "Grav cropResize fits-inside, not crop-to-fill — blurry cover/banner images"
date: 2026-07-07
category: ui-bugs
module: intotheeast-theme
problem_type: ui_bug
component: rails_view
symptoms:
- "Trip banner/cover renders blurry and badly cropped even though the source photo looks high-res in the post"
- "A portrait phone photo appears as a thin, upscaled horizontal sliver in a wide banner strip"
- "Cover derivative comes back at the source aspect ratio (e.g. 165x220 from a 1013x1350 portrait) instead of the requested strip"
root_cause: wrong_api
resolution_type: code_fix
severity: medium
tags: [grav, twig, medium, cropresize, cropzoom, srcset, retina, cover-image, object-fit]
---
# Grav cropResize fits-inside, not crop-to-fill — blurry cover/banner images
## Problem
The shared trip-cover macro produced a blurry, badly-composed banner/card image
for any trip whose cover fell back to a portrait journal photo. It looked like a
low-quality source, but the source was fine — the wrong Grav Medium operation was
turning it into a tiny sliver that CSS then upscaled.
## Symptoms
- Trip banner on `/trips/us-canada-mex-2024` looked "horrendous" — soft and
zoomed — while the same photo looked sharp inside the journal post.
- The rendered `<img>` derivative came back at the *source* aspect ratio, not the
requested strip: `cropResize(720, 220)` on a 1013×1350 portrait produced a
**165×220** image (0.75 ratio, matching the source), not a 720×220 strip.
- The banner box (`.trip-header-banner img { object-fit: cover; height: 200px }`)
then upscaled that ~165px-wide sliver ~4× to fill the column → blur.
## What Didn't Work
- **Assuming it was source/image quality.** The imported photos are only
~7001200px wide (pixelfed served downscaled web renditions), but that alone
did not explain the blur — the same file was sharp in the post.
- **Capping the derivative width to avoid upscaling (`min(w, source_width)`), as
a first pass.** This stopped Grav from re-encoding an upscaled JPEG, but the
derivative was *still* a portrait sliver because `cropResize` was still the
wrong operation — it emitted odd intermediate `srcset` widths (`1013w`,
`1200w`) without fixing the composition. It was treating a symptom.
## Solution
Switch the cover operation from `cropResize` (fit-inside) to `cropZoom`
(crop-to-fill / cover), and make retina all-or-nothing so a narrow source is
never upscaled.
```twig
{# BEFORE — cropResize fits the source INSIDE the box, preserving its aspect
ratio, so a portrait comes back as a narrow sliver #}
<img src="{{ cover.cropResize(w, h).url }}"
srcset="{{ cover.cropResize(w, h).url }} {{ w }}w,
{{ cover.cropResize(w * 2, h * 2).url }} {{ (w * 2) }}w">
{# AFTER — cropZoom crops-to-fill, returning an actual w×h cover strip; the 2x
descriptor is emitted only when the source is genuinely >= 2w wide #}
<img src="{{ cover.cropZoom(w, h).url }}"
srcset="{{ cover.cropZoom(w, h).url }} {{ w }}w{% if cover.width >= (w * 2) %}, {{ cover.cropZoom(w * 2, h * 2).url }} {{ (w * 2) }}w{% endif %}">
```
Verified empirically against the running container (do not trust the method
names from memory — Grav's op semantics are non-obvious):
| op | on a 1013×1350 portrait, target 720×220 | shape |
|----|------------------------------------------|-------|
| `cropResize(720, 220)` | **165×220** | fit-inside (source aspect kept) |
| `cropZoom(720, 220)` | **720×220** | crop-to-fill (cover) ✅ |
| `resize(720, 220)` | 720×220 | stretched/distorted ✗ |
## Why This Works
Grav's `Medium::cropResize($w, $h)` scales the image to **fit inside** the
`$w × $h` box while preserving the source aspect ratio — for a tall portrait it
is bound by height, yielding a narrow image far smaller than `$w`. `cropZoom`
instead scales to **cover** the box and crops the overflow, so it always returns
exactly `$w × $h` with no distortion. A banner/card strip wants cover behavior,
so `cropZoom` is correct. Capping widths at `cover.width` prevents Grav from
re-encoding an upscaled derivative; combined with `object-fit: cover` on the
element, the browser gets a sharp strip at (or below) native resolution.
Note the imported photos cap at ~1440px wide, so `cover.width >= 2w` is usually
false for the wide banner — auto-picked covers render 1x-only (sharp on standard
displays; retina only engages for an explicitly-set wide landscape `cover_image`).
## Prevention
- **Choose the Grav Medium op by intent, and verify the output dimensions.**
For a fixed-shape strip/thumbnail (banner, card, avatar) use `cropZoom`
(crop-to-fill). Use `cropResize` only when you actually want the whole image
fit inside a bounding box (aspect preserved, letterbox-friendly).
- **Confirm Medium API behavior empirically before shipping** rather than trusting
method names — a quick `php bin/grav` script that runs the op and calls
`getimagesize()` on the derivative catches fit-vs-fill surprises. (auto memory
[claude]: this repo's standing guidance is to look up / verify Grav + plugin
API behavior, never guess it.)
- **Guard retina descriptors against upscaling:** only add the 2x `srcset`
candidate when `cover.width >= 2 * targetWidth`; never emit a derivative wider
than the source.
- **Regression test the composition, not just the URL.** Assert the loaded
banner image's natural aspect ratio is the wide strip ratio (e.g. `nw/nh > 3`),
which fails if a future edit reverts to a fit-inside sliver. See
`tests/ui/trip/trip-header.spec.js` (portrait-source regression on
`us-canada-mex-2024`).
## Related Issues
- Feature that introduced the macro: `docs/working/plans/2026-07-05-trip-description-and-hero.md`
(see the 2026-07-07 follow-up note). Session history shows the retina cover
macro was built entirely with `cropResize` across the feature sessions and
`cropZoom` was never evaluated, so the bug was latent from inception and only
surfaced when real portrait content hit the banner. (session history)
- Backlog: full-resolution re-import of pixelfed photos — `docs/working/backlog.md`
(Content quality — luxury). The ~1440px source ceiling is why auto covers are
1x-only.