Notes › Tech Notes › Three Reasons the Small Text Smudges When a Screenshot Is Shrunk to a Quarter

Three Reasons the Small Text Smudges When a Screenshot Is Shrunk to a Quarter

Last month we redid the site, and the ops team threw over a batch of 1280×720 interface screenshots while the card slot only allowed 320×180. I recomputed width and height at 1/4, and the images bounced back the same day: the 12px small text inside them had smudged into one blob and the vertical strokes broke apart. The 1024×576 export I tried in the same batch dropped the pixel count to 64% yet the file grew from 62744 B to 79480 B. So I measured the code that actually runs on this page, 16 sets in all.

Lay the three reasons out first

  1. The measured canvas reads imageSmoothingQuality="low", and lines 220-224 of the template contain 0 resampling assignments, so what takes effect is the browser default.
  2. The whole template has only one drawImage, on line 224, so 1280×720 down to 320×180 is a single imaging pass: 320×180 gives 12310 B, 160×90 gives 5023 B, 12×7 gives 215 B, and every one of them came out in one step.
  3. The canvas always equals the requested value, because doResize only uses the raw width and height from lines 221/222: the non-proportional 800×600 gives 69321 B, which is 2.42 times the 28596 B of the proportional 640×360 bracket - distorted first, then sampled once by the default kernel.

Reason one: this page never set a resampling quality

canvas.width=w
canvas.height=h
const ctx=canvas.getContext('2d')
ctx.drawImage(originalImage,0,0,w,h)

Not one of those 4 statements (lines 221 through 224) touches imageSmoothingEnabled or imageSmoothingQuality, and a grep over the whole file hits 0 times. After shrinking to 320×180 I read the context returned on line 223 straight away, and the measurement was true and "low"; upscale-image.html in the same folder writes true and 'high' explicitly on lines 317-318. It is not that the browser has no high quality setting, it is that this page never asked for one.

Why that is fatal for small text: a 1px vertical stroke cannot even fill one whole pixel at a 1/4 ratio, and every neighbouring sample it can borrow is decided by that default kernel. My first batch of card images came out of this very page, Adjust PNG size.

Reason two: however far you shrink, there is only one sampling pass

Line 224 is the only drawing statement in the template: there is no second drawImage, no halving loop, and no branch that splits the work by ratio, so 4 times, 2 times and 8 times all walk the same road. Every bracket in the measured table - 160×90 gives 5023 B, 12×7 gives 215 B - is the result of one imaging pass. A single kernel sample presses the energy of a 4×4 neighbourhood of the source into 1 pixel at once, with no second chance to correct the strokes.

A line has to be drawn here: I cannot say that shrinking in two steps would be sharper or would save bytes, because the template has no second step at all, and the 1280→640→320 two-step comparison never came out this round; section 1.4 of the facts file is marked unmeasured. This point only claims that you get one sampling pass, and that one pass uses a default kernel nobody ever assigned.

Reason three: the canvas only obeys the two numbers in the input boxes

Lines 211/212 read width and height with parseInt (800.9 truncates to 800), lines 221/222 hand them to the canvas unchanged, and in all 16 measured sets both the canvas and the decoded size equalled the requested value exactly; 321×181 also outputs 14297 B, which is 16.1% larger than the 12310 B of 320×180.

Line 106 initialises aspectLocked as true, lines 188-193 refill the height as Math.round(w/aspectRatio) while the lock is on, line 189 returns directly once it is off, and doResize never reads it at all - the lock is only an input-box helper, not a scaling constraint: 800×600 is a 16:9 screenshot pushed into a 4:3 canvas, carrying one scaling ratio in each direction, and then handed to the default kernel for one single-step sample.

Shrinking does not always save file size

Requested sizeOutput bytesMeasured note
320×18012310 BCanvas exact, 19.6% of source
640×36028596 B45.6% of source
1024×57679480 BPixel count down to 64%, 26.7% above source
1280×72062744 BSame size round trip, checksum also 6e58cae9
Source 62744 B 320×180 12310 B, 19.6% of source 640×360 28596 B, 45.6% of source 1024×576 79480 B (+26.7%) Pixel count down to 64% 1280×720 62744 B round trip

Figure 1: the output bytes of the 1280×720 source (62744 B) at four requested brackets, the dashed line being the source size.

19.6% is only how this one picture happened to land; 640×360 at 45.6% of source is not proportional to it: resizing promises dimensions, it never promises file size.

Upscaling and distortion both add to the bill

800×600 69321 B non-proportional 640×360 28596 B proportional 69321 B is 2.42 times that 1920×1080 210255 B (3.35 times) 2560×1440 294510 B (4.69 times)

Figure 2: the real output bytes for the non-proportional request and the upscaling brackets.

The slider does not govern file size either: the range on line 64, min=10 max=100 value=90, is only shown for image/jpeg and image/webp (lines 152-153), and under PNG the whole row is hidden, yet line 229 still passes parseInt(quality)/100 unconditionally into toBlob on line 236. What that changes under PNG was not measured this round, so I will not write that dragging the slider can push the size down. If bytes really matter, go through PNG compression; how far that slider number travels is measured separately in the quality passthrough breakdown.

The transparent logo I measured on the side

The 512×512 transparent logo (23119 B) shrinks to 64×64 for 2454 B, and its alpha distribution is 1941 fully transparent, 303 semi-transparent and 1852 fully opaque, 4096 in total. Fully transparent pixels are 47.4% of those 4096, the output is still image/png, no white background was laid underneath and there was no switch to JPEG; but 303 semi-transparent pixels appeared along the edges, which means the alpha values themselves were rewritten by resampling. That is exactly why the copy on line 331, 缩放后Alpha透明背景不丢失, is judged too strong.

For the rest of the claims I only dare read them off the source: line 133 caps it at file.size > 20 * 1024 * 1024, lines 216-218 only check w>10000, and line 246 turns shot.png into shot_resized.png. The runtime toast was never captured this round, and chapter 7 of the facts file is marked unmeasured in full.

Three questions

Q: so can that 1/4 bracket still be used at all?

On the size side it is exact: the canvas is precisely 320×180 and the interface copy reads "320 × 180 | 12.0 KB". But it cannot give any guarantee that the small text is still readable afterwards - there is no second drawImage and not one imageSmoothingQuality assignment anywhere, so smudging is the default state.

Q: is going one bracket up a better deal than shrinking?

210255 B and 294510 B correspond to 3.35 times and 4.69 times, while the source is only 62744 B.

Q: why are there no grey level counts, edge widths or stem IoU numbers here?

Because they never came out. The probe broke on the set where the 512×512 was upscaled to 1024×1024: two runs, set to --virtual-time-budget=120000 and 900000 respectively, both stopped in the same place, so all three metrics together with the two-step downsampling comparison are blank and chapter 3 of the facts file only keeps the measuring method. Rather than invent a percentage that looks like a measurement, I would rather put the gap in the open: every claim this article makes about smudging stops at the mechanism level.

📎 Related articles

PNG compressed three times only lost 426 bytes: the real boundary of lossless compression 2026-10-22 How Many Times Bigger a PNG Gets After Converting From WebP: When It Is Unavoidable and How to Keep the Growth Down 2026-10-22 Cropped to the Same 300x300, Why Does the Avatar Always Sit Half a Cell Low-Right 2026-10-22 Forecasting 18 months at 1.7 times a month: why the script and the sheet give two numbers 2026-10-22 Which values quietly change when you paste API JSON into a PHP array 2026-10-22