Both files are 300×300, so why does the avatar sit half a cell low and right
I cropped the same photograph twice and both output files measure exactly 300×300. The first box I drew by hand, typing 300 and 300 into the two size fields. For the second one I tapped a ratio preset and let the tool gather the selection inward from the whole image so that it lands centred. Paste both results onto a flat solid colour and zoom in until one pixel becomes one square on your monitor: on the second file the white gap on the left is 1 pixel wider than the gap on the right, and the gap on top is 1 pixel taller than the gap below, so the person looks like they slid half a cell toward the lower-right corner. A flat background has no texture to hide that single pixel, so the fault shows plainly on an avatar.
Neither of the two usual suspects explains it. The export is not a blurry resample: it calls the nine-argument form of drawImage (T292), where the source rectangle's width and height are identical to the destination's, and a coordinate-encoding test image proves that dest(0,0) decodes back to source (0,0) with no interpolation at all. The selection is not half a pixel wide either, because the entire template contains exactly one rounding helper, clampInt (T191), and x, y, width and height all pass through it. The real explanation is that the coordinates get rounded several times in a row, by layers that disagree about direction. Along the image crop code path you will find a centring division, a pointer coordinate divided by the display scale, and whatever drawImage does when it is handed fractions.
Rounding direction: every half steps toward +∞
Math.max(lo, Math.min(hi, Math.round(v) || lo))
clampInt rounds first and only then squeezes the result into the allowed range. The direction is easy to enumerate: round(0.5)=1, round(2.5)=3, round(-0.5)=0, so a perfect half always carries toward positive infinity. That is not floor, not ceil, and not the banker's rounding that snaps a half to the nearest even number. Type 10.7 and it becomes 11 first, then the lower bound MIN=12 lifts it again (T102). Any intermediate value carrying .5 therefore occupies one extra cell on the lower-right side, which is exactly where the visible bias comes from.
The centring division, and which side keeps the remainder
The half cell is born inside the ratio presets' centring logic: the block size is derived from the ratio first, and then (oldW - w) / 2 is handed to clampInt as well (T177 and T178). As long as oldW - w comes out odd, that division must carry .5, and .5 always rounds upward. On a 401×299 source, choosing 0.75 yields 224×299, the leftover is 177, 177 / 2 = 88.5, and clampInt(88.5, 0, 177) returns 89, so the left margin ends up 89 while the right margin is 88. The whole block therefore sits 0.5 pixels right and below the geometric centre, and inside the saved file that reads as one extra pixel on a specific edge. Picking 1:1 on the same source is clean, because 299×299 leaves remainder 102, an even number, so (401 - 299) / 2 = 51 and you get 51 on each side.
Fig. 1 | the same 401×299 source centred twice, once with an even remainder and once with an odd remainder.
The larger the block, the easier the defect hides: 1080×1350 at the 1.777778 preset gives 1080×607 and (1350 - 607) / 2 = 371.5 → y=372, so 372 pixels stay above the selection while 371 remain underneath; 720×541 at 1.333333 gives 720×540@(0,1), one source row shorter and shifted down one cell.
The pixels on your screen are not the pixels in the file
Display runs a second coordinate system: scale=Math.min(1, Math.min(520, innerWidth-80)/natW) (T152-153). In this run innerWidth=764, so a source that is 640 wide was drawn with scale=0.8125, and the 520×325 preview canvas (T196) is a resample produced by the five-argument drawImage (T201). The position of the selection box passes through four further independent Math.round calls (T204-207): sel=(17,23,300,300) → left=14px, top=19px, width 244px. Judging centring by eye has a precision of one cell, and that 244×244 box on the screen was never the same pixel set as the 300×300 written into the file.
Fractions leak in at the conversion step. Mapping the pointer back into source coordinates is toNat=(clientX-r.left)/scale (T221), so clientX=100 computes 123.0769, and only when pointerup fires does clampInt (T249) collapse it into 123.
An experiment that isolates the off-by-one
I pushed one 300×300 crop through both paths, drew it once per path, and then read which source cell decodes back at dest(0,0):
- Origin (0.5,0.5): the direct draw lands back at (0,0) because the browser floors the fraction on its own, while the template path evaluates clampInt(0.5, 0, 340)=1 and lands at (1,1). One cell apart, and always toward the lower right.
- Origin (10.7,20.3): the direct draw lands at (10,20) while the template hands you (11,20), so x moves one cell right and y stays at 20.
- Origins (1,1) and (0,0): the two paths agree exactly, and integer coordinates show no drift at all.
Every drift traces back to a fractional origin being rewritten by Math.round. One side steps toward +∞ and the other toward -∞, and the half value sitting between them is precisely the half cell your avatar lost.
The size fields advertise min=1, yet 1 pixel never happens
The inputs #cp-w and #cp-h both declare min="1" (T27/T29), while the lower bound in JS is MIN=12 (T102) and the upper bound is natW, with a falsy short-circuit in if (w) (T183) on top of that:
| You type | sel.w | Reason |
|---|---|---|
| 1 | 12 | the lower bound is MIN=12 |
| 100.6 | 101 | fractions travel toward +∞ |
| 0 or blank | 200 | a falsy value is short-circuited away |
| 1000 | 640 | the upper bound is natW |
Clamping each axis separately then quietly breaks the ratio you were promised: at the 1.333333 preset, typing w=1001 clamps the width down to 640 and the height down to 400, so w/h=1.600000 because T185 never recomputes it. Padding outside the image only really happens when the source is smaller than 12: feed in an 8×6 picture with w=30 and you get sel=12×200, the lower-right corner reads RGBA=0,0,0,0, and the png weighs 308B. On ordinary paths clamp pushes you back inside the picture (T186/T245/T275).
The cell you lost may be a transparent pixel
PNG preserves alpha one pixel at a time: on a gradient source built as floor(255*x/639), cropping 200×100@(100,50) still reads 39, 79, 119 at the left, centre and right of the result. Only when the mime is image/jpeg does the code lay a background down first (T288-291), and there the pixel at (200,100,50) with alpha 39 blends into 31,15,8 over black.
Orientation obeys the same coordinate space: photo_exif6.jpg records 1200×900 in its SOF marker while the browser decodes it as 900×1200, and the template only ever reads naturalWidth (T151), so every coordinate lives in the already-rotated space. Under keep-the-original-format, image/gif, image/webp and image/bmp all degrade into image/png (T287), so frame-by-frame work has to begin by splitting a GIF into frames.
The template never rescales on its own: cropping 300×300 and then resizing to 150×150, versus resizing to 320×200 first and then cropping 150×150, both finish at png=1346B, and in either order dest(1,1) is source (2,2), because the resize step had already moved the sampling grid by one cell (image resize).
Q: Same preset, sometimes half a cell off and sometimes dead centred — what decides it?
Whether the difference between oldW and w is odd or even. 401×299 with 0.75 leaves 177 → 88.5 → 89, which means left 89 and right 88; with 1:1 it leaves 102 → 51, so both sides get 51 and nothing looks shifted.
Q: Can I nudge the selection by half a cell by typing decimals into the size fields?
No. x, y, width and height all pass through clampInt and every half travels toward +∞: 100.6 turns into 101, 0.5 turns into 1, 10.7 turns into 12. The status line that reads "selection 300×300 (origin 17,23)" never shows a decimal either, so the interface cannot even express the position you are aiming at.
Q: When the crop is off by that half cell, does the avatar go soft?
It does not. The export is the nine-argument drawImage with a source rectangle equal to the destination rectangle, and the probe confirms dest(0,0) picks up source (0,0) exactly. The only soft thing in the whole flow is the 520×325 preview canvas inside the editing area, and that canvas never reaches the file.