canvas.toBlob(callback, type, quality) encodes asynchronously; prefer it to toDataURL(), which blocks and returns Base64 a third larger. PNG is the default, quality (0 to 1) applies to JPEG and WebP, and an unsupported type falls back to PNG, so check blob.type.
<canvas id="c" style="width:64px; height:86px; float:left; margin-right:12px"></canvas>
<script src="hidpi.js"></script><pre id="out" style="margin:0"></pre>
<script>
const ctx = hidpi(c), encode = (type, q) => new Promise(done => c.toBlob(done, type, q));
ctx.fillStyle = '#2a9d8f'; ctx.fillRect(0, 0, 64, 86);
ctx.fillStyle = '#fff3e0'; ctx.font = 'bold 12px Georgia'; ctx.fillText('Gardens', 6, 20);
(async () => {
for (const [type, q] of [['image/png'], ['image/jpeg', 0.9], ['image/jpeg', 0.5],
['image/webp', 0.8]])
out.textContent += `${type} ${q ?? ''} -> ${(await encode(type, q)).size} bytes\n`;
const link = Object.assign(document.body.appendChild(document.createElement('a')), {
download: 'gardens-cover.png', textContent: 'Download gardens-cover.png' });
link.href = URL.createObjectURL(await encode('image/png')); __done = true;
})();
</script>
To upload instead, put the blob in a FormData (form.append('cover', blob, 'cover.png')) and fetch() it with method: 'POST': the server receives an ordinary file upload, with no Base64 to strip.
<!doctype html>
<style>
body { margin: 0; padding: 8px; font: 13px system-ui, sans-serif; background: #fafaf7; max-width: 600px; }
.row { display: flex; gap: 12px; align-items: flex-start; }
#cover { width: 120px; height: 170px; }
#chart { width: 460px; height: 170px; background: #fff; border: 1px solid #ddd; }
</style>
<div class="row"><canvas id="cover"></canvas><canvas id="chart"></canvas></div>
<p><a id="link">Preparing download…</a></p>
<script>
function hidpi(c, r = devicePixelRatio) {
c.width = c.clientWidth * r; c.height = c.clientHeight * r;
const ctx = c.getContext('2d'); ctx.scale(r, r); return ctx;
}
const c = document.getElementById('cover');
const ctx = hidpi(c);
// A small cover to encode, with a gradient so the lossy formats have work to do
const g = ctx.createLinearGradient(0, 0, 120, 170);
g.addColorStop(0, '#2a9d8f'); g.addColorStop(1, '#264653');
ctx.fillStyle = g; ctx.fillRect(0, 0, 120, 170);
ctx.fillStyle = '#fff3e0'; ctx.font = 'bold 18px Georgia';
ctx.fillText('Gardens', 10, 34); ctx.fillText('in Glass', 10, 56);
for (let i = 0; i < 6; i++) {
ctx.fillStyle = `hsl(${140 + i * 12} 50% ${40 + i * 6}%)`;
ctx.beginPath(); ctx.ellipse(30 + i * 13, 140 - (i % 2) * 18, 9, 22, 0.3, 0, 7); ctx.fill();
}
// toBlob is asynchronous: wrap it in a promise
const encode = (type, q) => new Promise(done => c.toBlob(done, type, q));
const chart = hidpi(document.getElementById('chart'));
(async () => {
const formats = [['image/png'], ['image/jpeg', 0.9], ['image/jpeg', 0.5], ['image/webp', 0.8]];
const results = [];
for (const [type, q] of formats) {
const blob = await encode(type, q);
// An unsupported type falls back to PNG, so report blob.type, not the request
results.push({ label: `${blob.type} ${q ?? ''}`, size: blob.size });
}
const max = Math.max(...results.map(r => r.size));
chart.font = '12px sans-serif';
chart.fillStyle = '#263238'; chart.fillText('Encoded size (bytes)', 10, 18);
results.forEach((r, i) => {
const y = 34 + i * 33, w = r.size / max * 250;
chart.fillStyle = '#263238'; chart.fillText(r.label, 10, y + 16);
chart.fillStyle = ['#1f5f8b', '#e09a10', '#f2c46d', '#3f7d3a'][i];
chart.fillRect(130, y, w, 22);
chart.fillStyle = '#263238'; chart.fillText(r.size.toLocaleString(), 136 + w, y + 16);
});
// A download link for the PNG, from an object URL rather than a Base64 data URL
const link = document.getElementById('link');
link.href = URL.createObjectURL(await encode('image/png'));
link.download = 'gardens-cover.png';
link.textContent = 'Download gardens-cover.png';
})();
</script>