#1883·thumbor

no_upscale() cancels fill()'s canvas expansion, so a no-upscale pad does not pad

Author: nlemoineCreated Aug 26, 2026Updated Aug 27, 2026

Combining no_upscale() with fill() on a fit-in request returns the content size instead of the requested box. The padding disappears entirely, so it is the canvas that is wrong and not just the bars.

Repro on 7.7.7, using one of your own test fixtures as the source so it runs as is:

bash
docker run -d --name tb -p 8877:8888 ghcr.io/thumbor/thumbor:latest
S="raw.githubusercontent.com/thumbor/thumbor/master/tests/fixtures/images/20x20.jpg"

for f in "no_upscale():fill(ff0000)" "fill(ff0000):no_upscale()" "fill(ff0000)"; do
  curl -s "http://localhost:8877/unsafe/fit-in/400x300/filters:${f}:format(png)/$S" -o out.png
  printf '%-30s -> %s\n' "$f" "$(identify -format '%wx%h' out.png)"
done
no_upscale():fill(ff0000)      -> 20x20
fill(ff0000):no_upscale()      -> 20x20
fill(ff0000)                   -> 400x300

The third line is the control. fill() on its own does expand the canvas to the requested box, and adding no_upscale() takes that away. Both filter orders behave the same.

Looking at the source, no_upscale runs in PHASE_AFTER_LOAD and does not cap the resize, it rewrites the request:

python
self.context.request.width = min(self.context.request.width, image_size[0])
self.context.request.height = min(self.context.request.height, image_size[1])

fill then builds its canvas from those same two values:

python
target_width = (
    self.context.request.width
    if self.context.request.width != 0
    else self.engine.size[0]
)

So by the time fill runs the target box is already the source size, and there is nothing left to pad.

If that reading is right it is not specific to fill, anything downstream reading request.width/height as the target box would see the clamped value rather than the requested one. I have only measured fill.

I do not have a fix to propose, since no_upscale clamping the request is presumably deliberate for the plain resize case. Maybe the clamp needs to apply to the resize step and leave the requested box alone. I work at the URL layer against thumbor and have not read enough of the transform pipeline to say.

For what it is worth, imagor takes the same filter grammar and pads correctly for both orders, which is what made me look at this in the first place.