Why the Two Boxes Fight Each Other on One and the Same Image
Drag in a self-made 1237x859 solid-color image, not a photo. With the lock on, type 800 into the width box and the height box is written back with "556". Then type 300 by hand into the height box: it snaps straight back to "556". Over at the slider side, the same PNG resized down to 619x430 was dragged from quality 10 to 100, and all four result lines read "619 x 430 | 6.8 KB". The page template is 12308 bytes / 265 lines long, and the measurement script ran that code twice exactly as written: once against a node:vm stub, once against the real encoders of Chrome 154.
The page under test is the Image Resize tool, and the whole page runs locally: a grep for fetch( returns 0 hits.
The Lock Reads Only the Width Box and Writes Only the Height Box
onSizeInput (L188-L192), while the lock is engaged, reads only the width box, computes only Math.round(width / aspectRatio), and writes only the height box. What aspectRatio actually stores is that float quotient 1237/859=1.440046565774156, not a reduced fraction, so a height typed by hand is guaranteed to be overwritten: width 800 plus a typed height 300 leaves "556" sitting in the height box. toggleLock (L181-L185) disengages the lock, and from then on the two boxes simply proceed on their own.
| Width box while locked | Height box | Raw quotient before rounding |
|---|---|---|
| 859 | 597 | 596.5084882780922 |
That same locked ruler was swept across 18 stops on the original image (1237->859, 400->278); when the width is typed as 1, rounding carries the height up and keeps it at 1 instead of collapsing it.
Percent Is Two Rulers Running Independently
onPercentInput (L195-L200) calls Math.round once per axis and never looks at aspectLocked. pct=50 gives 619x430, where the ideal values are 618.5 x 429.5; the sum of the two boxes is 1049 against 1048, off by 1. For p, exactly 10 stops land both axes on a .5 tail at once, and Math.round always carries a positive .5 upward. Sweeping pct from 1 to 1000, there are 120 stops where the two paths disagree on the height.
Figure 1: at the pct=1 stop the two paths hand back two different aspect ratios, which is the clearest single illustration of how the rounding rules differ.
The Ratio Box Never Reads the Width and Height Boxes Back
switchMode (L172-L178) only changes currentMode and the display style of the two panels; not one line of code writes back into resize-percent. Click 150%, switch back to pixel, and type 400 into the width by hand (the height is then computed as 278): throughout this the ratio field still shows "150". The back-solved value 100*1856/1237=150.0404% is not equal to the 150 sitting in the box, because what the box stores is "the stop you clicked". Clicking setPercent(150) once more (L203-L206) slams both boxes back to 1856x1289, and the 400 just typed simply evaporates.
Why the Slider Moves Not a Single Byte on a PNG
The local Chrome 154 ships only three real encoders: on a 1x1 canvas, png / jpeg / webp return 110 / 762 / 552 B respectively. L229 divides the slider value by 100 and hands it to toBlob, but the PNG encoder does not accept that parameter at all. BMP stacks one more twist on top: L228 rewrites the output type to PNG, while L152 shows the quality row only for image/jpeg / image/webp, and the slider itself is display:none there.
| blob at 619x430 | q10 | q50 | q90 | q100 |
|---|---|---|---|---|
| PNG | 6971 | 6971 | 6971 | 6971 |
| JPEG | 2338 | 2340 | 2341 | 3921 |
| WEBP | 1092 | 1102 | 1050 | 4684 |
For JPEG, only 3 B separate the readings along 10->90. For WEBP, q90 comes out smaller than q10 (1050<1092), so the encoder makes no monotonicity promise. A BMP source of 3188662 B came out at 6971 B on all six stops as well. Several sibling pages share this very template, and the byte accounts of those other pages stay outside this article. For clarity: the four PNG columns above all re-encode one and the same blob, they are not four separate resize runs.
How the Result-Line String Is Assembled
L235 is w + ' x ' + h + ' | ' + formatSize(blob.size), where w/h come from the integers held in JS rather than the canvas attributes, and the ' x ' separator is 3 code units / 4 bytes wide. formatSize (line 165 to line 169) has three tiers: B prints the integer unchanged, KB uses toFixed(1), MB uses toFixed(2), with thresholds at 1024 and 1048576. Measured: 1024->"1.0 KB" and 1075->"1.0 KB", but 1076 already rolls to "1.1 KB", and 1048576 reads "1.00 MB". Byte reduction travels a different channel entirely, covered by the image compression guide; the measured quality passthrough test profiles a different page.
Three Gates, and Everything They Do Not Catch
doResize (L209-L237) runs only three checks: no image loaded, parseInt produced NaN or a value at or below 0, or w>10000 || h>10000. JS itself sets no lower bound; min="1" / max="10000" are merely control attributes. The browser does not block a typed "10001" either - execution only reaches L216, where the alert "Size cannot exceed 10000px" fires. The quietest case of all is "1e5": parseInt stops reading at the letter e and returns 1, so there is no toast and an image really is produced, with the result line reading "1 x 1 | 762 B" - the input and the output sit five orders of magnitude apart. Only the slider gets hard-clamped by the browser (typing 9 snaps it to "10"), which is why L229 always lands inside 10..100.
What Stays on Screen After a Failure
Once "1 x 1 | 762 B" has been produced, typing w=619 / h=0 triggers the alert "Please enter a valid width and height", and the result line still shows the previous sentence. The more consequential case is the one that never lands: first a webp at 619x430 goes out ("619 x 430 | 1.0 KB", 1050 B), then a webp at 10000x10000 is requested - the callback never arrives, yet the canvas attributes were already synced to 10000x10000 by L221-L222. The result line still shows the previous sentence, and the download is still the earlier 1050 B. After resetAll (L253-L263), the seven state variables read character-for-character unchanged.
Batch handling is a measured "none": files.length appears 0 times, for( appears 0 times, progress appears 0 times, and the two occurrences of files[ both index only [0]; dropping in 2 files at once sends only the first one to handleFile. The resampling algorithm and imageSmoothingQuality (the page never reads it, and there is no pixel-level criterion from which to back out the kernel), the queue and its timing (no queue code, hence no object to measure), and the rendered interface pixels (no renderer criterion) are all marked as not measured.
A Side Check Against the Official Blurb
The image-resize entry in config/tools.php makes 16 claims, judged one by one: 6 consistent, 7 partially consistent, 3 conflicting. The three conflicts: "the other side adjusts" is only half true; the ID-photo stop yields 205 from an input of 295 while locked rather than the promised 413, an offset of -208 that requires unlocking first, which the blurb never mentions; and "thumbnail batch" has no channel at all.
FAQ
Q: Why does a hand-typed height bounce back while the lock is on?
The lock writes in one direction only, width to height, so the hand-typed value is overwritten during that write-back step.
Q: pct 50 yields 619x430 - is that proportional?
It is Math.round run once per axis: 619/430=1.4395348837209303 differs from the source ratio by -0.0355%, and inside 1..1000 there are 120 stops where it disagrees with the locked ruler.
Q: Why do the bytes not change even at the slider end?
PNG, and BMP rewritten into PNG by L228, do not consume the quality parameter at all, so four rounds print the identical line; only JPEG / WEBP change byte counts.