Build1 publisher2 min readPublished
canvas.toBlob() hands back a PNG when you ask it to encode AVIF
Browsers decode eight image formats and encode three. A dev.to post documents what happens when you ask the canvas API for one of the other five: the success callback fires, and the blob it carries is a PNG.
The Engineer · Build desk
What happened
- A dev.to post on hand-rolling a PNG compressor notes that canvas.toBlob() encodes only PNG, JPEG and WebP, so anything else has to be written byte by byte in JavaScript.
- Passed a MIME type it cannot encode, toBlob does not throw, does not return null and does not warn: it calls the success callback with a PNG blob.
- The post's fix is a two-line wrapper that resolves only when blob.type equals the MIME type requested, and otherwise rejects with an error naming the format the browser will not export.
- Re-encoding a PNG through toBlob('image/png') often produces a file larger than the original, because PNG is lossless and the encoder has no quality parameter to act on.
- Every scanline of raw PNG pixel data needs a leading filter byte, zero meaning no filter, and omitting it makes the decoded image come out sheared diagonally.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- constraint Offering GIF, BMP, AVIF, SVG or ICO output from a page means writing the encoder yourself, because the canvas API will produce none of those five.
- exposure The people holding the mislabelled files are users: the download works without an error, and the first report of the problem arrives as a bug.
- decision A converter's test suite has to assert the returned type, because a fired callback and a non-empty blob are both satisfied by the failure case.
- cost The byte-level path takes developer time: the author puts each of the post's four traps at an evening.
In the post's example the call asks for `image/avif` with a quality of 0.8, and the logged `blob.type` comes back as `image/png` [5]. A test that asserts the callback ran and the blob is not null passes on exactly that call [7]. "Never trust the callback firing as proof of anything," the author wrote [8].
The post does not measure how much shipped code does this. It documents the behaviour and the check that catches it [24]. The eight formats it lists as decodable are PNG, JPEG, WebP, GIF, BMP, AVIF, SVG and ICO [1].
The second trap costs bytes instead of correctness. The saving in a real PNG optimiser comes from colour depth, not from a compression level: a 24-bit pixel can hold about 16 million values, while a logo might use twelve and a screenshot two hundred [11]. Store a one-byte index into a palette of at most 256 colours and each pixel costs one byte instead of three or four, which the canvas encoder will never do for you [12]. That is a 67 to 75 percent cut in the pixel data before zlib sees it [13].
Writing the file yourself means four chunks for an indexed image: IHDR carrying dimensions, bit depth and colour type 3 for palette, then PLTE, then tRNS if anything is transparent, then IDAT [15]. Bit depth is worth packing properly. Two colours fit in one bit per pixel, four in two bits, sixteen in four, so a two-colour image stored at eight bits per pixel spends seven of every eight bits on nothing, which the post rounds to 87 percent of its bytes wasted [17][18]. zlib will compress the padding for you, at no benefit to anybody.
The last argument in the chain is the one most likely to be typed wrong. `CompressionStream` accepts `deflate`, `deflate-raw` and `gzip`; `deflate` emits zlib format, RFC 1950, with a two-byte header and an Adler-32 checksum, and `deflate-raw` emits RFC 1951 with neither [19]. PNG's IDAT payload is zlib-compressed, and browsers now ship that codec [20]. So `deflate` is the correct argument here, and a stream built with `deflate-raw` writes IDAT bytes missing the header and checksum a PNG decoder is reading for [21].
For a converter that never uploads the file, all of this is the cost of keeping the file local. The post's stated reason for staying in the browser is the input: server-side converters take an upload and hand back a download link, which the author is fine with for a meme and not for a scan of a passport [23].
What to watch
- Whether any browser adds a fourth toBlob encoder, which would move the three-format ceiling the post describes.
- Whether the canvas specification or an implementation starts rejecting an unsupported MIME type instead of falling back to PNG.
- A measured survey of client-side converters checking returned blob.type would test how widespread the mislabelled output is.