Hitting an image size ceiling with Rust and WebAssembly
Many image tools expose a quality slider. Forms and government portals usually expose a different requirement: “the file must be under 100 KB.” Those are not the same problem. A quality value is an encoder input. A byte ceiling is a constraint on the output, and the relationship between the two depends on the pixels, dimensions, color profile, output format, and encoder. I wanted a HEIC converter with one simple invariant: output_bytes <= target_kb * 1024 The implementation runs in the browser, with Rust compiled to WebAssembly. Here are the design choices that mattered. Decode once and keep a bounded image object HEIC decoding is expensive enough that repeating it for preview, rotation, compression, and conversion is wasteful. The WebAssembly boundary therefore exposes an image object that owns one decoded RGB buffer. The browser creates the object from the HEIC bytes, requests a JPEG preview, changes rotation if needed, and then calls either compress or convert . Replacing the input explicitly frees the old object. The decoder has pixel and memory limits before it returns that buffer. This is not optional in a browser tab: a small compressed file can decode into a very large allocation. Define “precise size” as a ceiling An encoder cannot promise an output of exactly 100,000 bytes without padding or a more complicated rate-control system. Padding has no user value, and a 99 KB file satisfies a 100 KB form. So the product contract is “at or below the selected target.” The UI reports the actual result. Tests assert the byte inequality, not a rounded label. That also avoids the KB ambiguity hiding in the interface. Internally, the target is converted once to bytes and every comparison uses that value. JPEG: search quality before dimensions For JPEG, encoded size is sufficiently ordered by quality to make binary search practical. The algorithm searches a protected quality interval rather than the entire 1-100 range: Q_MIN = 55 search [55, 100] for the highest quality whose bytes fit The minimum is a product decision. Very low JPEG quality can technically hit a target while producing obvious blocks around faces and text. Below the floor, reducing dimensions usually gives a more usable result. There is a useful optimization for tight targets: encode at Q_MIN first. If even that output is too large, the other quality probes at the same dimensions are doomed. Skip them and resize. If current_bytes is the failed encoding, estimate the next scale from image area: scale = sqrt(target_bytes / current_bytes) * 0.95 The square root appears because both width and height change. The 0.95 factor leaves a small margin so the next attempt is less likely to miss by a few bytes. Resize from the original decoded pixels with a high-quality Lanczos filter, then repeat the quality search. The result is the highest JPEG quality at the largest dimensions found under the ceiling, subject to the quality floor and iteration limit. PNG is a different algorithm Lossless PNG has no quality knob comparable to JPEG. Compression level and filter choices do not produce a clean monotonic control that can be binary-searched against output size. For PNG, dimensions are the practical lever: - estimate an initial downscale for very large photos, avoiding a slow full-resolution PNG encode; - encode the resized pixels and compare real bytes; - continue scaling down until the output fits; - try a few “grow-back” steps from the original pixels to use more of the remaining byte budget. The grow-back step matters. A conservative scale can produce a 92 KB image for a 100 KB target. Increasing both dimensions slightly may retain more detail and still fit. Each candidate is resized from the original RGB buffer, not from the already reduced image, to avoid accumulating blur. JPEG does not need this phase because quality search already spends the available budget at a fixed dimension. Keep conversion separate from compression “Convert HEIC to JPEG” and “compress HEIC below 100 KB” are different user intents. The conversion path uses the original pixel dimensions and a fixed high JPEG quality, or lossless PNG. It does not run the target-size loop. The output can be larger than the HEIC input, especially for PNG, and the interface should say so. Mixing these paths creates surprising tools that label an operation “convert” while quietly shrinking a photo to meet an arbitrary size. Preserve color information when possible A technically correct byte result can still look wrong if its color profile disappears. The decoder extracts an ICC profile when available, and both the preview and final encoder receive it. Using the same profile for the “before” preview and output also prevents a misleading comparison where only one side is color-managed. Make the WebAssembly contract boring The JavaScript-facing result contains bytes plus explicit metadata: output, MIME type, extension, input width/height, output width/height, JPEG quality or null for PNG, encoding passes, resize passes That shape keeps UI code out of the compression algorithm. The page creates object URLs for preview and download, revokes them when the file changes, and never sends source or result bytes to a server. Test the invariant with real HEIC files Synthetic RGB patterns are useful for forcing difficult compression, but they skip the HEIC decoder-the most format-specific part of the pipeline. The test suite includes a repository-owned phone photo and checks several targets for both JPEG and PNG. Useful assertions include: - output bytes never exceed the target; - JPEG quality never falls below the floor; - output dimensions are positive and preserve orientation; - conversion retains the original dimensions; - ICC data survives when the fixture contains it; - encoding and resize pass counts remain bounded; - the browser can download and decode the result; - no image-derived request leaves the page. The reference implementation is open source as HEIC To Size under AGPL-3.0-only. The larger lesson is that a byte target should be modeled as an invariant with format-specific control variables, not as a renamed quality slider. Top comments (0)
Comments
No comments yet. Start the discussion.