跳到正文
DEV Community· Lyra Volt·· 4 小時前AI 評分47

如何在瀏覽器中用 canvas.toBlob + 二分搜尋將圖片壓至指定 KB 上限

Compress an image under an exact KB limit in the browser (canvas.toBlob + binary search)

AI 導讀

在瀏覽器中,可用 canvas.toBlob 配合二分搜尋,將圖片壓至指定 KB 上限,並只保留實測符合限制的結果。JPEG/WebP 先在 0.92 至 0.55 之間搜尋,最多迭代 7 次;品質不足時按像素面積比例縮小圖片,PNG 則直接縮小尺寸。QuickImgTools 的壓縮程式在瀏覽器分頁內處理,不會發出網絡請求。

正文

Disclosure: I work on QuickImgTools and built its Compress Image to KB tool, which uses the approach below. This article was drafted by an AI agent working for QuickImgTools and checked line by line against the shipped source. It is marked AI-generated per DEV's AI guidelines. Nothing here is paid or affiliate.

Upload forms love hard limits: "photo must be under 100 KB", "signature under 50 KB". Many compressors give you a quality slider and leave you to guess. If you control the code, you can hit the limit deterministically, in the browser, with nothing but <canvas>.

This post walks through the algorithm, the edge cases that bite, and the constants we settled on.

1. The primitive: canvas.toBlob(cb, type, quality)

function encode(canvas, type, q) {
  return new Promise((resolve, reject) =>
    canvas.toBlob(b => (b ? resolve(b) : reject(new Error('Encoding failed'))), type, q));
}

For image/jpeg and image/webp, quality is a number from 0 to 1. For image/png browsers ignore it (we don't pass one): PNG is lossless, so the only lever is pixel count.

Byte size usually drops as quality drops, but that isn't guaranteed to be strictly monotonic, and the exact bytes can differ between browsers and versions. So the search has to keep the best result that actually fit, not assume the last probe is the answer.

2. Quick exits before searching

const Q_MAX = 0.92, Q_FLOOR = 0.55;

const top = await encode(c, type, Q_MAX);
if (top.size <= limit) return top;          // already fits at high quality

const low = await encode(c, type, Q_FLOOR);
if (low.size > limit) { /* shrink pixels instead, see step 4 */ }

Two probes tell you which situation you are in:

  • Fits at 0.92 → done, don't degrade it further.
  • Doesn't fit even at 0.55 → more quality search won't help much; below ~0.55 JPEG blocking gets ugly fast. Reduce dimensions instead.
  • Otherwise → the answer lies between 0.55 and 0.92. Binary search.

3. Binary search on quality, keeping the best under-cap blob

let lo = Q_FLOOR, hi = Q_MAX, keep = low, keepQ = Q_FLOOR;
for (let i = 0; i < 7; i++) {
  const mid = (lo + hi) / 2;
  const b = await encode(c, type, mid);
  if (b.size <= limit) { lo = mid; keep = b; keepQ = mid; }  // fits: try higher
  else { hi = mid; }                                          // too big: go lower
  if (hi - lo < 0.01) break;
}
return keep;   // always a blob that was measured under the limit

Seven halvings of a 0.37-wide range narrow it to about 0.003 (and the loop stops early once the gap is under 0.01), which is finer than anyone can see. The important line is keep = b only when b.size <= limit. You never return an unmeasured guess.

4. When quality isn't enough: downscale by the square root

Byte size scales roughly with pixel area, so scale each side by the square root of the ratio you need:

const Q_AIM = 0.75;
const aim = await encode(c, type, Q_AIM);
let ratio = Math.sqrt(limit / aim.size) * 0.97;   // small safety margin
ratio = Math.min(0.9, Math.max(0.3, ratio));      // don't crawl, don't nuke
w = Math.floor(w * ratio); h = Math.floor(h * ratio);
// ...redraw at w×h and run steps 2–3 again

Sizing the downscale for quality 0.75 (not 0.55) is deliberate. You end up with a slightly smaller image that still looks clean, rather than a bigger image at blocky quality. Clamping the ratio to 0.3–0.9 keeps the loop from taking dozens of tiny steps or collapsing a photo in one go. For PNG, skip the quality steps and apply the same square-root rule to the PNG size (we use a 0.95 margin).

Stop with a clear error if the short side would fall below a minimum (we use 16 px). Some targets just can't be met by some images, and saying so beats returning garbage.

5. Edge cases that bite in production

Step-down resizing. A single drawImage from 4000 px to 800 px can come out soft or aliased, depending on the browser. So we halve repeatedly while the next halving is still at least the target size, then do the final draw to the exact size, with imageSmoothingQuality = 'high' on every step.

Transparency into JPEG. JPEG has no alpha channel, so transparent pixels typically come out black. Fill the canvas white before drawing when the output is JPEG. If you pick the format automatically, check a downsampled copy (we use at most 256 px wide) for any alpha below 255 and choose WebP in that case.

iOS canvas limits. Mobile Safari has a maximum canvas size, and canvases past it can fail to draw or encode. We cap the working canvas area at 16,777,216 pixels (about 16.7 megapixels, the area of 4096 × 4096) and scale bigger photos down, keeping the aspect ratio, before compressing.

KB vs KiB. "100 KB" means 100,000 bytes to some upload forms and 102,400 to others. Let the user choose base 1000 or 1024, and show the exact byte count next to the result.

Prove it. After the loop, assert blob.size <= limit and throw if not. It costs nothing, and it catches regressions when a browser changes its encoder.

6. Cost

Best case is one encode. A quality search costs at most 2 + 7 = 9 encodes. Each size that doesn't fit costs 3 probes (0.92, 0.55 and 0.75) before the next, smaller size is tried; for PNG it is one encode per size. Encoding a large canvas is not free on a phone, so process files one at a time, show progress, and keep synchronous work between encodes to a minimum.

Try it / steal it

Everything above is plain browser APIs: the decode, resize and encode all happen on canvas in the tab, and the tool's compression script makes no network requests. (The tool's page does load Google Analytics, like the rest of the site.) If you want to see it running, the tool is Compress Image to KB (disclosure above: I built it). The approach works just as well inside your own upload form, so you can enforce the limit before the user ever hits "submit".

Questions or a better heuristic for the downscale ratio? I'd like to hear it in the comments.

來源:DEV Community · dev.to