Kemp.rest / Examples / Code / Image optimizer
KEMP.REST / EXAMPLES / CODE
Image optimizer
Written by Kevin Kemp in HTML, CSS, and JavaScript for PressCare. The work happens in the browser. Nothing is uploaded.
View the codeThis is the image tool in the Tools menu on presscare.com. A person drops in photos, sets a maximum size and a quality, and downloads web-ready files. The original bytes stay on the device.
Type of code
A self-contained code block: one style sheet, the form, and one script. No libraries and no requests.
Technology
HTML, CSS, and JavaScript. The canvas decodes, resizes, and re-encodes. Output is WebP, JPG, or PNG, and the input list also accepts GIF. PNG (compressed) is a small indexed-PNG encoder in the script: median-cut palette, error diffusion, CRC32, and deflate chunks. A zip writer packages the batch. Quality applies to JPG and WebP. Aspect ratio is kept and images are never enlarged.
What it does
- Accepts a drop or a file picker, including several files at once.
- Scales down to a max width and height by halving before the final draw, then encodes at the chosen quality.
- Re-encoding through the canvas drops EXIF and GPS. The visible image stays.
- If the new file is larger than the original, it keeps the original bytes and marks the file already optimal.
- Shows original and optimized sizes, then downloads one file or the whole batch as a zip.
Problem it solved
Page images needed to be resized, compressed, and stripped of location data without sending the files to a server or asking for an account.
Code
Back to summary · presscare.com/free-online-image-optimizer/
<style>
.imgo-app{
--imgo-ground:transparent;
--imgo-card:#0B2350;
--imgo-ink:#EDEAE3;
--imgo-muted:#9AACC9;
--imgo-accent:#F6B210;
--imgo-accent-dark:#D99A05;
--imgo-line:rgba(237,234,227,.18);
--imgo-line-soft:rgba(237,234,227,.08);
--imgo-good:#8FD6AE;
--imgo-bad:#F0B0A6;
--imgo-radius:10px;
box-sizing:border-box;
background:var(--imgo-ground);
color:var(--imgo-ink);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
font-size:16px;
line-height:1.55;
padding:clamp(20px,4vw,48px) clamp(16px,4vw,32px);
}
.imgo-app *,.imgo-app *::before,.imgo-app *::after{box-sizing:inherit}
.imgo-shell{max-width:920px;margin:0 auto}
.imgo-app h1,.imgo-app h2,.imgo-app h3{margin:0;font-weight:650;letter-spacing:-.01em;line-height:1.2;color:var(--imgo-ink) !important}
.imgo-app h1{font-size:clamp(28px,4.2vw,40px)}
.imgo-app h2{font-size:22px;margin-bottom:12px}
.imgo-app h3{font-size:17px}
.imgo-app p{margin:0}
.imgo-lede{margin-top:10px;color:var(--imgo-muted);max-width:62ch}
/* ---------- controls ---------- */
.imgo-panel{
background:var(--imgo-card);
border:1px solid var(--imgo-line);
border-radius:var(--imgo-radius);
padding:18px;
margin-top:24px;
}
.imgo-controls{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:16px 20px}
.imgo-field{display:flex;flex-direction:column;gap:6px;min-width:0}
.imgo-field>span{font-size:13px;font-weight:600;color:var(--imgo-muted)}
.imgo-app select,.imgo-app input[type=number]{
width:100%;font:inherit;font-size:15px;color:#09255C;
background:#FFFFFF;
color:#09255C;
border:1px solid rgba(9,37,92,.18);border-radius:8px;padding:9px 10px;
}
.imgo-app input[type=number]::placeholder{color:#5B6472;opacity:.75}
.imgo-app input[type=range]{width:100%;accent-color:var(--imgo-accent);margin:6px 0 0}
.imgo-qval{font-variant-numeric:tabular-nums;font-weight:600;color:var(--imgo-ink)}
.imgo-hint{font-size:13px;color:var(--imgo-muted)}
.imgo-applied{font-size:14px;font-weight:650;color:var(--imgo-ink)}
.imgo-applied em{font-style:normal;font-weight:400;color:var(--imgo-muted)}
.imgo-check{display:flex;align-items:center;gap:8px;font-size:14px;color:var(--imgo-muted);margin-top:14px}
.imgo-check input{accent-color:var(--imgo-accent);width:16px;height:16px}
.imgo-app [hidden]{display:none !important}
/* ---------- dropzone ---------- */
.imgo-drop{
margin-top:16px;width:100%;display:block;
background:var(--imgo-card);
border:2px dashed var(--imgo-line);
border-radius:var(--imgo-radius);
padding:40px 20px;text-align:center;cursor:pointer;
font:inherit;color:var(--imgo-ink);
transition:border-color .15s ease,background-color .15s ease;
}
.imgo-drop:hover{border-color:var(--imgo-accent)}
.imgo-drop.imgo-over{border-color:var(--imgo-accent);border-style:solid;background:#123063}
.imgo-drop strong{display:block;font-size:17px;font-weight:650}
.imgo-drop span{display:block;margin-top:6px;font-size:13px;color:var(--imgo-muted)}
/* ---------- results ---------- */
.imgo-summary{
display:flex;flex-wrap:wrap;align-items:center;gap:12px 16px;
margin-top:24px;padding:14px 16px;
background:var(--imgo-card);border:1px solid var(--imgo-line);border-radius:var(--imgo-radius);
}
.imgo-summary p{font-size:15px}
.imgo-summary .imgo-spacer{flex:1 1 auto}
.imgo-saved{font-weight:650;color:var(--imgo-good)}
.imgo-report{
width:100%;margin-top:10px;min-height:180px;resize:vertical;
font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px;line-height:1.5;
color:var(--imgo-ink);background:var(--imgo-card);
border:1px solid var(--imgo-line);border-radius:var(--imgo-radius);padding:12px;
}
.imgo-list{list-style:none;margin:12px 0 0;padding:0;display:flex;flex-direction:column;gap:10px}
.imgo-item{
display:grid;grid-template-columns:64px minmax(0,1fr) auto;gap:14px;align-items:center;
background:var(--imgo-card);border:1px solid var(--imgo-line);border-radius:var(--imgo-radius);padding:12px;
}
.imgo-thumb{
width:64px;height:64px;border-radius:8px;object-fit:cover;
background:var(--imgo-ground);border:1px solid var(--imgo-line-soft);
}
.imgo-name{font-weight:600;overflow-wrap:anywhere;font-size:15px}
.imgo-from{font-size:12.5px;color:var(--imgo-muted);margin-top:1px;overflow-wrap:anywhere}
.imgo-meta{font-size:13px;color:var(--imgo-muted);margin-top:2px;font-variant-numeric:tabular-nums}
.imgo-meta b{color:var(--imgo-ink);font-weight:600}
.imgo-delta{font-weight:650;color:var(--imgo-good)}
.imgo-delta.imgo-flat{color:var(--imgo-muted)}
.imgo-actions{display:flex;align-items:center;gap:8px}
.imgo-err{color:var(--imgo-bad)}
/* ---------- buttons ---------- */
.imgo-btn{
font:inherit;font-size:14px;font-weight:650;
border-radius:8px;padding:9px 16px;cursor:pointer;
border:1px solid transparent;background:var(--imgo-accent);color:#09255C;
transition:background-color .15s ease,border-color .15s ease,color .15s ease;
}
.imgo-btn:hover{background:var(--imgo-accent-dark)}
.imgo-btn--quiet{background:transparent;border-color:var(--imgo-line);color:var(--imgo-ink)}
.imgo-btn--quiet:hover{background:rgba(255,255,255,.06);border-color:var(--imgo-accent)}
.imgo-btn--icon{padding:7px 10px;font-size:18px;line-height:1}
.imgo-btn[disabled]{opacity:.45;cursor:default}
.imgo-app :focus-visible{outline:3px solid var(--imgo-accent);outline-offset:2px}
/* ---------- prose ---------- */
.imgo-prose{margin-top:40px;max-width:68ch}
.imgo-prose h2{margin-bottom:14px}
.imgo-prose h3{margin-top:22px;font-size:16px}
.imgo-prose h3+p{margin-top:5px;color:var(--imgo-muted)}
.imgo-prose>p{color:var(--imgo-muted);margin-top:10px}
.imgo-rule{border:0;border-top:1px solid var(--imgo-line);margin:36px 0 0}
@media (max-width:560px){
.imgo-item{grid-template-columns:48px minmax(0,1fr);row-gap:10px}
.imgo-thumb{width:48px;height:48px}
.imgo-actions{grid-column:1/-1;justify-content:flex-end}
}
@media (prefers-reduced-motion:reduce){
.imgo-app *{transition:none !important}
}
body.page-id-399 #main .fusion-portfolio-content,
body.page-id-399 #main .fusion-portfolio-content h2,
body.page-id-399 #main .fusion-portfolio-content h3,
body.page-id-399 #main .fusion-portfolio-content h4,
body.page-id-399 #main .fusion-portfolio-content p,
body.page-id-399 #main .fusion-portfolio-content a,
body.page-id-399 #main .fusion-faq-shortcode .fusion-toggle-heading,
body.page-id-399 #main .fusion-faq-shortcode .panel-title a,
body.page-id-399 #main .fusion-faq-shortcode .toggle-content,
body.page-id-399 #main .fusion-faq-shortcode .toggle-content p{color:#EDEAE3 !important}
body.page-id-399 #main .fusion-toggle-heading .fa,
body.page-id-399 #main .fusion-toggle-heading .fontawesome-icon{color:#F6B210 !important}
</style>
<div class="imgo-app">
<div class="imgo-shell">
<header>
<h1>Image Optimizer</h1>
<p class="imgo-lede">Resize, compress and strip metadata in one pass. Set a maximum size and a quality, then download web-ready files. Your images never leave your device.</p>
</header>
<div class="imgo-panel">
<div class="imgo-controls">
<label class="imgo-field">
<span>Output format</span>
<select id="imgoFormat">
<option value="auto" selected>Keep original</option>
<option value="image/webp">WebP</option>
<option value="image/jpeg">JPG</option>
<option value="image/png">PNG</option>
<option value="png8">PNG (compressed)</option>
</select>
</label>
<label class="imgo-field">
<span>Quality <span class="imgo-qval" id="imgoQualityVal">80%</span></span>
<input type="range" id="imgoQuality" min="1" max="100" value="80">
</label>
<label class="imgo-field">
<span>Max width (px)</span>
<input type="number" id="imgoMaxW" min="1" step="1" placeholder="No limit" inputmode="numeric">
</label>
<label class="imgo-field">
<span>Max height (px)</span>
<input type="number" id="imgoMaxH" min="1" step="1" placeholder="No limit" inputmode="numeric">
</label>
</div>
<label class="imgo-check" id="imgoDitherRow" hidden>
<input type="checkbox" id="imgoDither" checked>
Dither when reducing colours — smoother gradients, slightly bigger files
</label>
<p class="imgo-applied" id="imgoApplied" style="margin-top:14px"></p>
<p class="imgo-hint" id="imgoQualityHint" style="margin-top:6px">Quality applies to JPG and WebP. Aspect ratio is always kept and images are never enlarged.</p>
</div>
<button type="button" class="imgo-drop" id="imgoDrop">
<strong>Drop images here, or click to choose</strong>
<span id="imgoFormats">JPG, PNG, WebP, GIF · multiple files · stays in your browser</span>
</button>
<input type="file" id="imgoFile" accept="image/*" multiple hidden>
<div class="imgo-summary" id="imgoSummary" hidden>
<p id="imgoTotals" aria-live="polite"></p>
<span class="imgo-spacer"></span>
<button type="button" class="imgo-btn" id="imgoZip">Download all (.zip)</button>
<button type="button" class="imgo-btn imgo-btn--quiet" id="imgoReport">Show report</button>
<button type="button" class="imgo-btn imgo-btn--quiet" id="imgoClear">Clear</button>
</div>
<textarea class="imgo-report" id="imgoReportBox" readonly hidden aria-label="Run report"></textarea>
<ul class="imgo-list" id="imgoList"></ul>
<hr class="imgo-rule">
<section class="imgo-prose">
<h2>How it works</h2>
<p>Drop in your images and set a maximum width and height, a quality and an output format. Each image is scaled down with its aspect ratio kept, re-encoded at your chosen quality and stripped of metadata. Original and optimized sizes sit side by side, and you can download one file or all of them as a zip.</p>
<p>Everything runs in your browser. Nothing is uploaded and there is no signup.</p>
<h2 style="margin-top:32px">Questions</h2>
<h3>How do resize and quality work together?</h3>
<p>Resizing cuts the pixel dimensions; a photo straight off a phone is usually far bigger than any slot on a page needs. Quality controls how hard the JPG or WebP encoder compresses. Resizing saves the most, so set a sensible max width first and then tune quality.</p>
<h3>What max dimensions should I use?</h3>
<p>Around 1600–2000px wide for full-width images, 1200px for images inside body copy, 400–600px for thumbnails. Leave the fields empty to keep the original dimensions and only compress.</p>
<h3>What is "PNG (compressed)"?</h3>
<p>It keeps a PNG as a PNG, transparency included, but rebuilds it with a smaller colour palette. That is a lossy step, and it shrinks logos, screenshots and flat graphics a great deal. The quality slider sets how many colours are kept. Plain PNG stays lossless. For photographs, WebP or JPG will almost always be smaller.</p>
<h3>Does it remove EXIF and GPS data?</h3>
<p>Yes. Re-encoding through a canvas drops all embedded metadata, including camera details and location. The visible image is unchanged.</p>
<h3>Can it ever make a file bigger?</h3>
<p>No. If the optimized result comes out larger than what you started with, your original bytes are kept and the file is marked "already optimal".</p>
</section>
</div>
</div>
<script>
(function(){
"use strict";
var $ = function(id){ return document.getElementById(id); };
var els = {
drop: $('imgoDrop'), file: $('imgoFile'), list: $('imgoList'),
format: $('imgoFormat'), quality: $('imgoQuality'), qVal: $('imgoQualityVal'),
qHint: $('imgoQualityHint'), maxW: $('imgoMaxW'), maxH: $('imgoMaxH'),
ditherRow: $('imgoDitherRow'), dither: $('imgoDither'),
summary: $('imgoSummary'), totals: $('imgoTotals'),
zip: $('imgoZip'), clear: $('imgoClear'), formats: $('imgoFormats'),
applied: $('imgoApplied'), report: $('imgoReport'), reportBox: $('imgoReportBox')
};
var items = [];
var seq = 0;
var runToken = 0;
/* ---------- helpers ---------- */
function fmtBytes(n){
if (n < 1024) return n + ' B';
if (n < 1024*1024) return (n/1024).toFixed(n < 10240 ? 1 : 0) + ' KB';
return (n/1048576).toFixed(2) + ' MB';
}
function baseName(name){
var i = name.lastIndexOf('.');
return i > 0 ? name.slice(0, i) : name;
}
function extFor(mime){
return { 'image/jpeg':'jpg', 'image/png':'png', 'image/webp':'webp' }[mime] || 'img';
}
function toBlob(canvas, mime, q){
return new Promise(function(res, rej){
canvas.toBlob(function(b){ b ? res(b) : rej(new Error('This browser could not encode ' + mime)); }, mime, q);
});
}
var canWebp = (function(){
var c = document.createElement('canvas');
c.width = c.height = 1;
return c.toDataURL('image/webp').indexOf('data:image/webp') === 0;
})();
if (!canWebp){
els.format.querySelector('option[value="image/webp"]').disabled = true;
els.formats.textContent = 'JPG, PNG, GIF · multiple files · stays in your browser';
}
function decode(file){
if (window.createImageBitmap){
return createImageBitmap(file, { imageOrientation:'from-image' })
.catch(function(){ return createImageBitmap(file); })
.catch(function(){ return decodeViaImg(file); });
}
return decodeViaImg(file);
}
function decodeViaImg(file){
return new Promise(function(res, rej){
var url = URL.createObjectURL(file);
var img = new Image();
img.onload = function(){ URL.revokeObjectURL(url); res(img); };
img.onerror = function(){ URL.revokeObjectURL(url); rej(new Error('Not an image this browser can read')); };
img.src = url;
});
}
function srcW(s){ return s.width || s.naturalWidth; }
function srcH(s){ return s.height || s.naturalHeight; }
function targetSize(w, h, maxW, maxH){
var scale = 1;
if (maxW && w > maxW) scale = Math.min(scale, maxW / w);
if (maxH && h > maxH) scale = Math.min(scale, maxH / h);
return [Math.max(1, Math.round(w * scale)), Math.max(1, Math.round(h * scale))];
}
// Halve repeatedly before the final draw: much cleaner than one big downscale.
function drawResized(src, w, h, flatten){
var sw = srcW(src), sh = srcH(src);
var cur = document.createElement('canvas');
cur.width = sw; cur.height = sh;
cur.getContext('2d').drawImage(src, 0, 0);
while (sw >= w * 2 && sh >= h * 2 && sw > 1 && sh > 1){
var nw = Math.max(w, Math.floor(sw / 2)), nh = Math.max(h, Math.floor(sh / 2));
var step = document.createElement('canvas');
step.width = nw; step.height = nh;
var sx = step.getContext('2d');
sx.imageSmoothingEnabled = true; sx.imageSmoothingQuality = 'high';
sx.drawImage(cur, 0, 0, nw, nh);
cur = step; sw = nw; sh = nh;
}
var out = document.createElement('canvas');
out.width = w; out.height = h;
var ox = out.getContext('2d');
if (flatten){ ox.fillStyle = '#ffffff'; ox.fillRect(0, 0, w, h); }
ox.imageSmoothingEnabled = true; ox.imageSmoothingQuality = 'high';
ox.drawImage(cur, 0, 0, w, h);
return out;
}
/* ---------- CRC32 (shared by PNG and ZIP) ---------- */
var CRC_TABLE = (function(){
var t = new Uint32Array(256), c, n, k;
for (n = 0; n < 256; n++){
c = n;
for (k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
t[n] = c >>> 0;
}
return t;
})();
function crc32(bytes){
var c = 0xFFFFFFFF;
for (var i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xFF] ^ (c >>> 8);
return (c ^ 0xFFFFFFFF) >>> 0;
}
/* ---------- indexed PNG encoder ---------- */
function medianCut(data, maxColors){
// Tally unique colours (sampled on very large images) then split boxes by widest axis.
var total = data.length / 4;
var step = total > 1500000 ? 3 : (total > 600000 ? 2 : 1);
var counts = new Map(), i, key;
for (i = 0; i < total; i += step){
var o = i * 4;
var a = data[o+3];
key = a === 0 ? 0 : (((data[o] << 24) | (data[o+1] << 16) | (data[o+2] << 8) | a) >>> 0);
counts.set(key, (counts.get(key) || 0) + 1);
}
var colors = [];
counts.forEach(function(count, k){
colors.push(k === 0
? { r:0, g:0, b:0, a:0, n:count }
: { r:(k >>> 24) & 255, g:(k >>> 16) & 255, b:(k >>> 8) & 255, a:k & 255, n:count });
});
if (colors.length <= maxColors) return colors.map(function(c){ return [c.r, c.g, c.b, c.a]; });
var boxes = [colors];
while (boxes.length < maxColors){
var pick = -1, best = -1;
for (i = 0; i < boxes.length; i++){
if (boxes[i].length < 2) continue;
var sp = spread(boxes[i]);
if (sp.range > best){ best = sp.range; pick = i; }
}
if (pick < 0) break;
var box = boxes[pick], ch = spread(box).axis;
box.sort(function(x, y){ return x[ch] - y[ch]; });
var half = box.reduce(function(s, c){ return s + c.n; }, 0) / 2, acc = 0, cut = 0;
for (i = 0; i < box.length - 1; i++){ acc += box[i].n; if (acc >= half){ cut = i + 1; break; } }
if (cut === 0) cut = Math.floor(box.length / 2) || 1;
boxes.splice(pick, 1, box.slice(0, cut), box.slice(cut));
}
return boxes.filter(function(b){ return b.length; }).map(function(b){
var r = 0, g = 0, bl = 0, al = 0, n = 0;
for (var j = 0; j < b.length; j++){
var c = b[j];
r += c.r * c.n; g += c.g * c.n; bl += c.b * c.n; al += c.a * c.n; n += c.n;
}
return [Math.round(r/n), Math.round(g/n), Math.round(bl/n), Math.round(al/n)];
});
}
function spread(box){
var lo = [255,255,255,255], hi = [0,0,0,0], keys = ['r','g','b','a'], i, k;
for (i = 0; i < box.length; i++){
for (k = 0; k < 4; k++){
var v = box[i][keys[k]];
if (v < lo[k]) lo[k] = v;
if (v > hi[k]) hi[k] = v;
}
}
// Alpha differences matter more than a single colour channel.
var w = [1, 1.2, 0.8, 1.6], axis = 0, range = -1;
for (k = 0; k < 4; k++){
var r = (hi[k] - lo[k]) * w[k];
if (r > range){ range = r; axis = k; }
}
return { axis: keys[axis], range: range };
}
function paletteMapper(palette){
var cache = new Int16Array(262144).fill(-1);
return function nearest(r, g, b, a){
var key = ((r >> 3) << 13) | ((g >> 3) << 8) | ((b >> 3) << 3) | (a >> 5);
var hit = cache[key];
if (hit >= 0) return hit;
var best = 0, bestD = Infinity;
for (var i = 0; i < palette.length; i++){
var p = palette[i];
var da = a - p[3];
var d = da * da * 3;
if (d < bestD){
var dr = r - p[0], dg = g - p[1], db = b - p[2];
d += dr*dr*0.9 + dg*dg*1.2 + db*db*0.7;
if (d < bestD){ bestD = d; best = i; }
}
}
cache[key] = best;
return best;
};
}
function quantize(imgData, colorCount, dither){
var w = imgData.width, h = imgData.height, data = imgData.data;
var palette = medianCut(data, colorCount);
// Transparent entries first so tRNS can stay short.
palette.sort(function(a, b){ return a[3] - b[3]; });
var nearest = paletteMapper(palette);
var idx = new Uint8Array(w * h);
var errR = null, errG = null, errB = null, errA = null;
if (dither){
errR = new Float32Array((w + 2) * 2); errG = new Float32Array((w + 2) * 2);
errB = new Float32Array((w + 2) * 2); errA = new Float32Array((w + 2) * 2);
}
for (var y = 0; y < h; y++){
var row = y & 1, next = row ^ 1, base = row * (w + 2), nbase = next * (w + 2);
if (dither){
errR.fill(0, nbase, nbase + w + 2); errG.fill(0, nbase, nbase + w + 2);
errB.fill(0, nbase, nbase + w + 2); errA.fill(0, nbase, nbase + w + 2);
}
for (var x = 0; x < w; x++){
var o = (y * w + x) * 4, e = base + x + 1;
var r = data[o], g = data[o+1], b = data[o+2], a = data[o+3];
if (dither){
r = clamp255(r + errR[e]); g = clamp255(g + errG[e]);
b = clamp255(b + errB[e]); a = clamp255(a + errA[e]);
}
var pi = nearest(r|0, g|0, b|0, a|0);
idx[y * w + x] = pi;
if (dither){
var p = palette[pi];
spreadErr(errR, errG, errB, errA, base, nbase, x, w,
r - p[0], g - p[1], b - p[2], a - p[3]);
}
}
}
return { palette: palette, indices: idx, width: w, height: h };
}
function clamp255(v){ return v < 0 ? 0 : (v > 255 ? 255 : v); }
function spreadErr(eR, eG, eB, eA, base, nbase, x, w, dr, dg, db, da){
var e = base + x + 1, n = nbase + x + 1;
if (x + 1 < w){ eR[e+1] += dr*7/16; eG[e+1] += dg*7/16; eB[e+1] += db*7/16; eA[e+1] += da*7/16; }
eR[n-1] += dr*3/16; eG[n-1] += dg*3/16; eB[n-1] += db*3/16; eA[n-1] += da*3/16;
eR[n] += dr*5/16; eG[n] += dg*5/16; eB[n] += db*5/16; eA[n] += da*5/16;
eR[n+1] += dr*1/16; eG[n+1] += dg*1/16; eB[n+1] += db*1/16; eA[n+1] += da*1/16;
}
function deflate(bytes){
if (typeof CompressionStream === 'undefined') return Promise.reject(new Error('no CompressionStream'));
var stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream('deflate'));
return new Response(stream).arrayBuffer().then(function(buf){ return new Uint8Array(buf); });
}
function chunk(type, data){
var out = new Uint8Array(12 + data.length);
var view = new DataView(out.buffer);
view.setUint32(0, data.length);
for (var i = 0; i < 4; i++) out[4+i] = type.charCodeAt(i);
out.set(data, 8);
view.setUint32(8 + data.length, crc32(out.subarray(4, 8 + data.length)));
return out;
}
function encodeIndexedPng(q){
var w = q.width, h = q.height, pal = q.palette, i;
var ihdr = new Uint8Array(13), dv = new DataView(ihdr.buffer);
dv.setUint32(0, w); dv.setUint32(4, h);
ihdr[8] = 8; // bit depth
ihdr[9] = 3; // colour type: indexed
ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
var plte = new Uint8Array(pal.length * 3);
for (i = 0; i < pal.length; i++){
plte[i*3] = pal[i][0]; plte[i*3+1] = pal[i][1]; plte[i*3+2] = pal[i][2];
}
var lastAlpha = -1;
for (i = 0; i < pal.length; i++) if (pal[i][3] < 255) lastAlpha = i;
var trns = null;
if (lastAlpha >= 0){
trns = new Uint8Array(lastAlpha + 1);
for (i = 0; i <= lastAlpha; i++) trns[i] = pal[i][3];
}
var raw = new Uint8Array(h * (w + 1));
for (var y = 0; y < h; y++){
raw[y * (w + 1)] = 0; // filter: none
raw.set(q.indices.subarray(y * w, y * w + w), y * (w + 1) + 1);
}
return deflate(raw).then(function(z){
var parts = [
new Uint8Array([137,80,78,71,13,10,26,10]),
chunk('IHDR', ihdr),
chunk('PLTE', plte)
];
if (trns) parts.push(chunk('tRNS', trns));
parts.push(chunk('IDAT', z), chunk('IEND', new Uint8Array(0)));
return new Blob(parts, { type:'image/png' });
});
}
/* ---------- optimize one file ---------- */
function settings(){
var maxW = parseInt(els.maxW.value, 10), maxH = parseInt(els.maxH.value, 10);
return {
format: els.format.value,
quality: parseInt(els.quality.value, 10) / 100,
maxW: maxW > 0 ? maxW : 0,
maxH: maxH > 0 ? maxH : 0,
dither: els.dither.checked
};
}
function resolveFormat(fmt, origType){
if (fmt !== 'auto') return fmt;
if (origType === 'image/jpeg' || origType === 'image/png' || (origType === 'image/webp' && canWebp)) return origType;
return 'image/png';
}
function optimize(item, cfg){
return decode(item.file).then(function(src){
var ow = srcW(src), oh = srcH(src);
var size = targetSize(ow, oh, cfg.maxW, cfg.maxH);
var mime = resolveFormat(cfg.format, item.file.type);
var canvas = drawResized(src, size[0], size[1], mime === 'image/jpeg');
if (src.close) src.close();
item.origW = ow; item.origH = oh;
item.outW = size[0]; item.outH = size[1];
item.canvasW = canvas.width; item.canvasH = canvas.height;
item.askedMime = mime;
item.resized = (size[0] !== ow || size[1] !== oh);
item.reformatted = (cfg.format !== 'auto');
item.cfg = cfg;
item.thumb = thumbFrom(canvas);
if (mime === 'png8'){
var colors = Math.max(2, Math.min(256, Math.round(cfg.quality * 256)));
var data = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height);
return encodeIndexedPng(quantize(data, colors, cfg.dither))
.catch(function(){ return toBlob(canvas, 'image/png'); })
.then(function(blob){ return { blob: blob, mime:'image/png' }; });
}
var q = (mime === 'image/jpeg' || mime === 'image/webp') ? cfg.quality : undefined;
return toBlob(canvas, mime, q).then(function(blob){ return { blob: blob, mime: mime }; });
}).then(function(res){
// A resize or a chosen format is an instruction, not a size optimization.
// Only fall back to the original bytes when neither was asked for.
var instructed = item.resized || item.reformatted;
item.encType = res.blob.type || '(none reported)';
item.encSize = res.blob.size;
if (res.blob.size >= item.file.size && !instructed){
item.blob = item.file;
item.outName = item.file.name;
item.kept = true;
item.grew = false;
} else {
item.blob = res.blob;
// Name by what the browser actually produced: toBlob silently falls back
// to PNG when it cannot encode the requested type.
item.outName = baseName(item.file.name) + '-optimized.' + extFor(res.blob.type || res.mime);
item.kept = false;
item.grew = res.blob.size > item.file.size;
}
return measure(item.blob).then(function(dims){
item.realW = dims[0]; item.realH = dims[1];
item.realType = item.blob.type || '(none reported)';
item.status = 'done';
item.error = '';
});
}).catch(function(err){
item.status = 'error';
item.error = err && err.message ? err.message : 'Could not optimize this file';
});
}
// Decodes the finished file and reads its real pixel size, so the row on
// screen reports the output rather than what the run intended to make.
function measure(blob){
return decode(blob).then(function(b){
var d = [srcW(b), srcH(b)];
if (b.close) b.close();
return d;
}).catch(function(){ return [0, 0]; });
}
function thumbFrom(canvas){
var s = 128;
var t = document.createElement('canvas');
var ratio = Math.min(s / canvas.width, s / canvas.height, 1);
t.width = Math.max(1, Math.round(canvas.width * ratio));
t.height = Math.max(1, Math.round(canvas.height * ratio));
var cx = t.getContext('2d');
cx.imageSmoothingQuality = 'high';
cx.drawImage(canvas, 0, 0, t.width, t.height);
return t.toDataURL('image/png');
}
/* ---------- run queue ---------- */
function runAll(){
var token = ++runToken;
var cfg = settings();
items.forEach(function(it){ it.status = 'working'; });
render();
var i = 0;
function next(){
if (token !== runToken) return;
if (i >= items.length){ render(); return; }
var item = items[i++];
optimize(item, cfg).then(function(){
if (token !== runToken) return;
render();
setTimeout(next, 0); // let the UI breathe between images
});
}
next();
}
/* ---------- rendering ---------- */
function render(){
els.summary.hidden = items.length === 0;
els.list.innerHTML = '';
var origTotal = 0, outTotal = 0, done = 0, working = 0;
items.forEach(function(item){
var li = document.createElement('li');
li.className = 'imgo-item';
var img = document.createElement('img');
img.className = 'imgo-thumb';
img.alt = '';
if (item.thumb) img.src = item.thumb;
li.appendChild(img);
var mid = document.createElement('div');
// Once a file is ready, the row is named for what you will download.
var displayName = item.status === 'done' ? item.outName : item.file.name;
var name = document.createElement('div');
name.className = 'imgo-name';
name.textContent = displayName;
mid.appendChild(name);
if (item.status === 'done' && item.outName !== item.file.name){
var from = document.createElement('div');
from.className = 'imgo-from';
from.textContent = 'from ' + item.file.name;
mid.appendChild(from);
}
var meta = document.createElement('div');
meta.className = 'imgo-meta';
if (item.status === 'working'){
working++;
meta.textContent = 'Optimizing…';
} else if (item.status === 'error'){
meta.className = 'imgo-meta imgo-err';
meta.textContent = item.error;
} else {
done++;
origTotal += item.file.size;
outTotal += item.blob.size;
var outW = item.realW || item.outW, outH = item.realH || item.outH;
var dims = (outW === item.origW && outH === item.origH)
? item.origW + '×' + item.origH
: item.origW + '×' + item.origH + ' → ' + outW + '×' + outH;
var kind = (item.realType || '').replace('image/', '');
meta.appendChild(document.createTextNode(dims + ' · ' + fmtBytes(item.file.size) + ' → '));
var b = document.createElement('b');
b.textContent = fmtBytes(item.blob.size) + (kind ? ' ' + kind : '');
meta.appendChild(b);
var tag = document.createElement('span');
if (item.kept){
tag.className = 'imgo-delta imgo-flat';
tag.textContent = ' · already optimal';
} else if (item.grew){
tag.className = 'imgo-delta imgo-flat';
tag.textContent = ' · +' + Math.round((item.blob.size / item.file.size - 1) * 100) + '% — try a lower quality';
} else {
tag.className = 'imgo-delta';
tag.textContent = ' · −' + Math.round((1 - item.blob.size / item.file.size) * 100) + '%';
}
meta.appendChild(tag);
}
mid.appendChild(meta);
li.appendChild(mid);
var actions = document.createElement('div');
actions.className = 'imgo-actions';
var dl = document.createElement('button');
dl.type = 'button';
dl.className = 'imgo-btn imgo-btn--quiet';
dl.textContent = 'Download';
dl.disabled = item.status !== 'done';
dl.addEventListener('click', function(){ saveBlob(item.blob, item.outName); });
actions.appendChild(dl);
var rm = document.createElement('button');
rm.type = 'button';
rm.className = 'imgo-btn imgo-btn--quiet imgo-btn--icon';
rm.textContent = '×';
rm.setAttribute('aria-label', 'Remove ' + displayName);
rm.addEventListener('click', function(){
items = items.filter(function(x){ return x !== item; });
render();
});
actions.appendChild(rm);
li.appendChild(actions);
els.list.appendChild(li);
});
if (!els.reportBox.hidden) els.reportBox.value = buildReport();
if (working){
els.totals.textContent = 'Optimizing ' + items.length + ' image' + (items.length === 1 ? '' : 's') + '…';
els.zip.disabled = true;
} else if (done){
var saved = origTotal - outTotal;
els.totals.innerHTML = '';
els.totals.appendChild(document.createTextNode(
done + ' image' + (done === 1 ? '' : 's') + ' · ' + fmtBytes(origTotal) + ' → ' + fmtBytes(outTotal) + ' · '));
var s = document.createElement('span');
s.className = 'imgo-saved';
s.textContent = saved > 0 ? fmtBytes(saved) + ' saved (' + Math.round(saved / origTotal * 100) + '%)' : 'nothing to save';
els.totals.appendChild(s);
els.zip.disabled = false;
} else {
els.totals.textContent = 'No images optimized yet.';
els.zip.disabled = true;
}
}
function saveBlob(blob, name){
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url; a.download = name;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(function(){ URL.revokeObjectURL(url); }, 2000);
}
/* ---------- zip (stored, no compression) ---------- */
function dosTime(d){
return ((d.getHours() << 11) | (d.getMinutes() << 5) | (Math.floor(d.getSeconds() / 2))) & 0xFFFF;
}
function dosDate(d){
return (((d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate()) & 0xFFFF;
}
function buildZip(files){
var enc = new TextEncoder(), now = new Date();
var locals = [], centrals = [], offset = 0;
files.forEach(function(f){
var nameBytes = enc.encode(f.name);
var crc = crc32(f.bytes), size = f.bytes.length;
var lh = new Uint8Array(30 + nameBytes.length);
var lv = new DataView(lh.buffer);
lv.setUint32(0, 0x04034b50, true);
lv.setUint16(4, 20, true);
lv.setUint16(6, 0x0800, true); // UTF-8 names
lv.setUint16(8, 0, true); // stored
lv.setUint16(10, dosTime(now), true);
lv.setUint16(12, dosDate(now), true);
lv.setUint32(14, crc, true);
lv.setUint32(18, size, true);
lv.setUint32(22, size, true);
lv.setUint16(26, nameBytes.length, true);
lv.setUint16(28, 0, true);
lh.set(nameBytes, 30);
var ch = new Uint8Array(46 + nameBytes.length);
var cv = new DataView(ch.buffer);
cv.setUint32(0, 0x02014b50, true);
cv.setUint16(4, 20, true);
cv.setUint16(6, 20, true);
cv.setUint16(8, 0x0800, true);
cv.setUint16(10, 0, true);
cv.setUint16(12, dosTime(now), true);
cv.setUint16(14, dosDate(now), true);
cv.setUint32(16, crc, true);
cv.setUint32(20, size, true);
cv.setUint32(24, size, true);
cv.setUint16(28, nameBytes.length, true);
cv.setUint32(42, offset, true);
ch.set(nameBytes, 46);
locals.push(lh, f.bytes);
centrals.push(ch);
offset += lh.length + size;
});
var cdSize = centrals.reduce(function(s, c){ return s + c.length; }, 0);
var eocd = new Uint8Array(22);
var ev = new DataView(eocd.buffer);
ev.setUint32(0, 0x06054b50, true);
ev.setUint16(8, files.length, true);
ev.setUint16(10, files.length, true);
ev.setUint32(12, cdSize, true);
ev.setUint32(16, offset, true);
return new Blob(locals.concat(centrals, [eocd]), { type:'application/zip' });
}
function downloadZip(){
var ready = items.filter(function(i){ return i.status === 'done'; });
if (!ready.length) return;
var used = Object.create(null);
els.zip.disabled = true;
els.zip.textContent = 'Zipping…';
Promise.all(ready.map(function(item){
return item.blob.arrayBuffer().then(function(buf){
var name = item.outName, n = 1;
while (used[name]){ name = baseName(item.outName) + '-' + (++n) + item.outName.slice(baseName(item.outName).length); }
used[name] = true;
return { name: name, bytes: new Uint8Array(buf) };
});
})).then(function(files){
saveBlob(buildZip(files), 'optimized-images.zip');
}).finally(function(){
els.zip.disabled = false;
els.zip.textContent = 'Download all (.zip)';
});
}
/* ---------- input wiring ---------- */
function addFiles(fileList){
var added = Array.prototype.filter.call(fileList, function(f){
return f.type.indexOf('image/') === 0 || /\.(jpe?g|png|webp|gif|bmp|avif)$/i.test(f.name);
});
if (!added.length) return;
added.forEach(function(f){
items.push({ id: ++seq, file: f, status:'working', thumb:'', blob:null, outName:f.name, kept:false });
});
runAll();
}
els.drop.addEventListener('click', function(){ els.file.click(); });
els.file.addEventListener('change', function(){ addFiles(els.file.files); els.file.value = ''; });
['dragenter','dragover'].forEach(function(ev){
els.drop.addEventListener(ev, function(e){ e.preventDefault(); els.drop.classList.add('imgo-over'); });
});
['dragleave','dragend','drop'].forEach(function(ev){
els.drop.addEventListener(ev, function(){ els.drop.classList.remove('imgo-over'); });
});
els.drop.addEventListener('drop', function(e){
e.preventDefault();
if (e.dataTransfer && e.dataTransfer.files) addFiles(e.dataTransfer.files);
});
document.addEventListener('dragover', function(e){ e.preventDefault(); });
document.addEventListener('drop', function(e){ e.preventDefault(); });
var timer = null;
function reprocess(){
clearTimeout(timer);
timer = setTimeout(function(){ if (items.length) runAll(); }, 300);
}
var FORMAT_LABELS = {
'auto':'Keeping each file in its own format',
'image/webp':'Converting to WebP',
'image/jpeg':'Converting to JPG',
'image/png':'Converting to PNG',
'png8':'Converting to PNG with a reduced palette'
};
// Mirrors back what the controls are set to, so it is obvious what a run will do.
function describeSettings(){
var cfg = settings();
var bits = [FORMAT_LABELS[cfg.format]];
if (cfg.maxW && cfg.maxH) bits.push('scaling to fit ' + cfg.maxW + ' × ' + cfg.maxH + ' px');
else if (cfg.maxW) bits.push('scaling to max width ' + cfg.maxW + ' px');
else if (cfg.maxH) bits.push('scaling to max height ' + cfg.maxH + ' px');
els.applied.textContent = bits.join(' · ');
if (!cfg.maxW && !cfg.maxH){
var note = document.createElement('em');
note.textContent = ' · not scaling — max width and height are empty';
els.applied.appendChild(note);
}
}
function syncQualityLabel(){
var fmt = els.format.value;
var png8 = fmt === 'png8';
els.qVal.textContent = png8
? Math.max(2, Math.min(256, Math.round(els.quality.value / 100 * 256))) + ' colours'
: els.quality.value + '%';
els.ditherRow.hidden = !png8;
els.qHint.textContent = png8
? 'Quality sets how many colours the palette keeps. Aspect ratio is always kept and images are never enlarged.'
: 'Quality applies to JPG and WebP. Aspect ratio is always kept and images are never enlarged.';
describeSettings();
}
els.quality.addEventListener('input', function(){ syncQualityLabel(); reprocess(); });
els.format.addEventListener('change', function(){ syncQualityLabel(); reprocess(); });
els.maxW.addEventListener('input', function(){ describeSettings(); reprocess(); });
els.maxH.addEventListener('input', function(){ describeSettings(); reprocess(); });
els.dither.addEventListener('change', reprocess);
function buildReport(){
var lines = ['Image Optimizer — run report', 'browser: ' + navigator.userAgent,
'webp encoding: ' + (canWebp ? 'supported' : 'NOT supported')];
items.forEach(function(item, n){
lines.push('');
lines.push((n + 1) + '. ' + item.file.name + ' [' + item.status + ']');
lines.push(' input ' + (item.file.type || '(unknown type)') + ' ' + item.file.size + ' B' +
(item.origW ? ' ' + item.origW + '×' + item.origH : ''));
if (item.cfg){
lines.push(' settings format=' + item.cfg.format + ' quality=' + item.cfg.quality +
' maxW=' + (item.cfg.maxW || 'empty') + ' maxH=' + (item.cfg.maxH || 'empty'));
lines.push(' target ' + item.outW + '×' + item.outH +
' canvas drawn at ' + item.canvasW + '×' + item.canvasH +
' asked for ' + item.askedMime);
}
if (item.status === 'done'){
lines.push(' encoded ' + item.encType + ' ' + item.encSize + ' B');
lines.push(' delivered ' + item.realType + ' ' + item.blob.size + ' B ' +
(item.realW ? item.realW + '×' + item.realH + ' (read back from the file)' : '(could not read back)'));
lines.push(' branch ' + (item.kept ? 'kept your original bytes' : 'used the optimized output') +
' filename: ' + item.outName);
} else if (item.status === 'error'){
lines.push(' error ' + item.error);
}
});
return lines.join('\n');
}
els.report.addEventListener('click', function(){
if (els.reportBox.hidden){
els.reportBox.value = buildReport();
els.reportBox.hidden = false;
els.report.textContent = 'Hide report';
if (navigator.clipboard) navigator.clipboard.writeText(els.reportBox.value).catch(function(){});
} else {
els.reportBox.hidden = true;
els.report.textContent = 'Show report';
}
});
els.zip.addEventListener('click', downloadZip);
els.clear.addEventListener('click', function(){
items = []; runToken++;
els.reportBox.hidden = true; els.report.textContent = 'Show report';
render();
});
syncQualityLabel();
render();
})();
</script>