Image Tool

Image Compressor

Compress and convert images in your browser. No uploads, fully private — everything runs locally.

Drop image here or click to upload
JPEG · PNG · WebP · GIF  /  max 50 MB
Developer Reference

Core Algorithm & Standalone Script

Standalone, zero-dependency JavaScript implementation powering this tool. Free to inspect, copy, and build upon.

(function () {
  'use strict';

  // ── STATE ────────────────────────────────────────────────────────
  let state = {
    file: null,
    imgEl: null,           // loaded HTMLImageElement (original)
    origWidth: 0,
    origHeight: 0,
    origBytes: 0,
    origDataURL: '',
    format: 'jpeg',        // 'jpeg' | 'png' | 'webp'
    quality: 80,
    maxWidth: null,
    compBlob: null,
    compObjectURL: null,
    debounceTimer: null,
  };

  // ── ELEMENTS ─────────────────────────────────────────────────────
  const uploadZone      = document.getElementById('uploadZone');
  const fileInput       = document.getElementById('fileInput');
  const toolPanel       = document.getElementById('toolPanel');
  const qualitySlider   = document.getElementById('qualitySlider');
  const qualityVal      = document.getElementById('qualityVal');
  const qualityField    = document.getElementById('qualityField');
  const maxWidthInput   = document.getElementById('maxWidthInput');
  const origImg         = document.getElementById('origImg');
  const origSizeEl      = document.getElementById('origSize');
  const compImg         = document.getElementById('compImg');
  const compPlaceholder = document.getElementById('compPlaceholder');
  const compSizeEl      = document.getElementById('compSize');
  const savingsPct      = document.getElementById('savingsPct');
  const savingsCaption  = document.getElementById('savingsCaption');
  const sizeOrig        = document.getElementById('sizeOrig');
  const sizeNew         = document.getElementById('sizeNew');
  const processingInd   = document.getElementById('processingIndicator');
  const downloadBtn     = document.getElementById('downloadBtn');
  const downloadLabel   = document.getElementById('downloadLabel');
  const resetBtn        = document.getElementById('resetBtn');
  const newImageBtn     = document.getElementById('newImageBtn');
  const canvas          = document.getElementById('workCanvas');

  // ── UTILS ────────────────────────────────────────────────────────
  function formatBytes(bytes) {
    if (bytes < 1024) return bytes + ' B';
    if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
    return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
  }

  function getMimeType(fmt) {
    return { jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp' }[fmt] || 'image/jpeg';
  }

  function getExtension(fmt) {
    return { jpeg: 'jpg', png: 'png', webp: 'webp' }[fmt] || 'jpg';
  }

  function buildOutputName(origName, fmt) {
    const dotIdx = origName.lastIndexOf('.');
    const base = dotIdx > 0 ? origName.slice(0, dotIdx) : origName;
    return base + '-compressed.' + getExtension(fmt);
  }

  function detectFormat(file) {
    const t = file.type;
    if (t === 'image/jpeg' || t === 'image/jpg') return 'jpeg';
    if (t === 'image/png') return 'png';
    if (t === 'image/webp') return 'webp';
    return 'jpeg'; // gif → convert to jpeg
  }

  function setFormatActive(fmt) {
    document.querySelectorAll('.fmt-btn').forEach(b => {
      b.classList.toggle('active', b.dataset.fmt === fmt);
    });
  }

  function updateQualityVisibility(fmt) {
    // PNG is lossless — grey out slider but still show it
    if (fmt === 'png') {
      qualityField.style.opacity = '0.4';
      qualityField.style.pointerEvents = 'none';
    } else {
      qualityField.style.opacity = '1';
      qualityField.style.pointerEvents = '';
    }
  }

  function revokeOldURL() {
    if (state.compObjectURL) {
      URL.revokeObjectURL(state.compObjectURL);
      state.compObjectURL = null;
    }
  }

  // ── FILE HANDLING ────────────────────────────────────────────────
  function handleFile(file) {
    if (!file || !file.type.startsWith('image/')) return;
    if (file.size > 50 * 1024 * 1024) {
      alert('File exceeds 50 MB limit.');
      return;
    }

    state.file = file;
    state.origBytes = file.size;
    state.format = detectFormat(file);
    state.quality = 80;
    state.maxWidth = null;

    // Reset UI controls
    qualitySlider.value = 80;
    qualityVal.textContent = '80%';
    maxWidthInput.value = '';
    setFormatActive(state.format);
    updateQualityVisibility(state.format);

    // Load image
    const reader = new FileReader();
    reader.onload = (e) => {
      state.origDataURL = e.target.result;
      const img = new Image();
      img.onload = () => {
        state.imgEl = img;
        state.origWidth = img.naturalWidth;
        state.origHeight = img.naturalHeight;

        // Show original preview
        origImg.src = state.origDataURL;
        origSizeEl.textContent = formatBytes(state.origBytes);

        // Show tool panel
        uploadZone.classList.add('hidden');
        toolPanel.classList.remove('hidden');

        // Trigger first compression
        compress();
      };
      img.src = e.target.result;
    };
    reader.readAsDataURL(file);
  }

  // ── COMPRESSION ──────────────────────────────────────────────────
  function compress() {
    if (!state.imgEl) return;

    // Show processing state
    compImg.classList.add('hidden');
    compPlaceholder.classList.remove('hidden');
    compPlaceholder.innerHTML = '<span class="spinner"></span> processing…';
    processingInd.classList.add('hidden');
    downloadBtn.classList.add('hidden');

    const fmt = state.format;
    const quality = state.quality / 100;
    const maxW = state.maxWidth;

    let drawW = state.origWidth;
    let drawH = state.origHeight;

    if (maxW && maxW < drawW) {
      drawH = Math.round((maxW / drawW) * drawH);
      drawW = maxW;
    }

    canvas.width = drawW;
    canvas.height = drawH;

    const ctx = canvas.getContext('2d');
    // For JPEG, fill with white background (no alpha channel)
    if (fmt === 'jpeg') {
      ctx.fillStyle = '#ffffff';
      ctx.fillRect(0, 0, drawW, drawH);
    } else {
      ctx.clearRect(0, 0, drawW, drawH);
    }
    ctx.drawImage(state.imgEl, 0, 0, drawW, drawH);

    const mime = getMimeType(fmt);
    const qualityArg = fmt === 'png' ? undefined : quality;

    canvas.toBlob((blob) => {
      if (!blob) {
        compPlaceholder.innerHTML = '<span style="color:var(--error);font-size:11px;">Compression failed.</span>';
        return;
      }

      revokeOldURL();
      state.compBlob = blob;
      state.compObjectURL = URL.createObjectURL(blob);

      // Update compressed preview
      compImg.src = state.compObjectURL;
      compImg.onload = () => {
        compPlaceholder.classList.add('hidden');
        compImg.classList.remove('hidden');
      };

      // Update stats
      const origB = state.origBytes;
      const compB = blob.size;
      const diff = origB - compB;
      const pct = Math.round((diff / origB) * 100);

      compSizeEl.textContent = formatBytes(compB);
      sizeOrig.textContent = formatBytes(origB);
      sizeNew.textContent = formatBytes(compB);

      if (pct >= 5) {
        savingsPct.textContent = '↓ ' + pct + '%';
        savingsPct.className = 'savings-pct';
        savingsCaption.textContent = 'smaller';
      } else if (pct > 0) {
        savingsPct.textContent = '↓ ' + pct + '%';
        savingsPct.className = 'savings-pct minimal';
        savingsCaption.textContent = 'smaller';
      } else if (pct === 0) {
        savingsPct.textContent = '0%';
        savingsPct.className = 'savings-pct minimal';
        savingsCaption.textContent = 'change';
      } else {
        savingsPct.textContent = '↑ ' + Math.abs(pct) + '%';
        savingsPct.className = 'savings-pct negative';
        savingsCaption.textContent = 'larger';
      }

      // Update download button
      const outputName = buildOutputName(state.file.name, fmt);
      downloadBtn.href = state.compObjectURL;
      downloadBtn.download = outputName;
      downloadLabel.textContent = 'Download ' + fmt.toUpperCase() + ' — ' + formatBytes(compB);
      downloadBtn.classList.remove('hidden');

      processingInd.classList.add('hidden');
    }, mime, qualityArg);
  }

  // ── DEBOUNCED RECOMPRESS ─────────────────────────────────────────
  function scheduleCompress() {
    if (!state.imgEl) return;
    processingInd.classList.remove('hidden');
    downloadBtn.classList.add('hidden');
    clearTimeout(state.debounceTimer);
    state.debounceTimer = setTimeout(compress, 300);
  }

  // ── EVENT LISTENERS ──────────────────────────────────────────────

  // Upload zone click/drag
  uploadZone.addEventListener('dragover', (e) => {
    e.preventDefault();
    uploadZone.classList.add('drag-over');
  });
  uploadZone.addEventListener('dragleave', () => uploadZone.classList.remove('drag-over'));
  uploadZone.addEventListener('drop', (e) => {
    e.preventDefault();
    uploadZone.classList.remove('drag-over');
    const file = e.dataTransfer.files[0];
    if (file) handleFile(file);
  });
  fileInput.addEventListener('change', (e) => {
    const file = e.target.files[0];
    if (file) handleFile(file);
    fileInput.value = ''; // reset so same file can be re-selected
  });

  // Quality slider
  qualitySlider.addEventListener('input', (e) => {
    state.quality = parseInt(e.target.value);
    qualityVal.textContent = state.quality + '%';
    scheduleCompress();
  });

  // Format buttons
  document.querySelectorAll('.fmt-btn').forEach(btn => {
    btn.addEventListener('click', () => {
      state.format = btn.dataset.fmt;
      setFormatActive(state.format);
      updateQualityVisibility(state.format);
      scheduleCompress();
    });
  });

  // Max width input
  maxWidthInput.addEventListener('input', (e) => {
    const v = parseInt(e.target.value);
    state.maxWidth = (!isNaN(v) && v > 0) ? v : null;
    scheduleCompress();
  });

  // Reset
  resetBtn.addEventListener('click', () => {
    if (!state.file) return;
    qualitySlider.value = 80;
    state.quality = 80;
    qualityVal.textContent = '80%';
    maxWidthInput.value = '';
    state.maxWidth = null;
    const defaultFmt = detectFormat(state.file);
    state.format = defaultFmt;
    setFormatActive(defaultFmt);
    updateQualityVisibility(defaultFmt);
    scheduleCompress();
  });

  // New image
  newImageBtn.addEventListener('click', () => {
    revokeOldURL();
    state.file = null;
    state.imgEl = null;
    state.compBlob = null;
    origImg.src = '';
    compImg.src = '';
    compImg.classList.add('hidden');
    compPlaceholder.classList.remove('hidden');
    compPlaceholder.innerHTML = '<span class="spinner"></span> processing…';
    downloadBtn.classList.add('hidden');
    toolPanel.classList.add('hidden');
    uploadZone.classList.remove('hidden');
    fileInput.value = '';
  });

  // Global paste support
  document.addEventListener('paste', (e) => {
    const items = (e.clipboardData || e.originalEvent.clipboardData).items;
    for (const item of items) {
      if (item.type.startsWith('image/')) {
        handleFile(item.getAsFile());
        break;
      }
    }
  });
})();