image

PASSPORT PHOTO

Crop to exact passport dimensions, remove background with AI, touch up with a manual brush, and export PNG or a print-ready PDF sheet. Everything runs in your browser — nothing is uploaded.

1
Upload
2
Crop
3
Background
4
Export
Passport Format
India — 35×45 mm
US — 2×2 in (51×51)
UK — 35×45 mm
China — 33×48 mm
EU — 35×45 mm
Australia — 35×45 mm
Upload Photo
DROP PHOTO HERE
or click to browse · JPG PNG WEBP supported
Position crop box over face and shoulders
Drag inside the box to move · Drag corners to resize · Ratio locked
Edit Background
Size 28
Soft 55%
Background Color
custom color
AI Removal

Automatically remove the background. Model (~20 MB) cached after first run.

or
Final Photo
Final passport photo
Single Photo
Print Sheet — PDF

Multiple photos on one page — take to any print shop.

Page Size
A4
Letter
4×6 Photo
5×7 Photo
Developer Reference

Core Algorithm & Standalone Script

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

// ──────────────────────────────────────────────
    //  State
    // ──────────────────────────────────────────────
    let originalImg   = null;   // HTMLImageElement from upload
    let croppedUrl    = null;   // data URL of cropped photo (flat, with BG)

    // Canvas-based BG editing
    let srcCanvas  = null;  // source pixels (cropped, never modified)
    let maskCanvas = null;  // mask: white=visible, transparent=erased
    let maskCtx    = null;
    let PHOTO_W = 0, PHOTO_H = 0;

    // Brush state
    let brushMode     = 'erase';
    let brushSize     = 28;
    let brushSoftness = 0.55;
    const brushCache  = {};
    let undoStack     = [];
    let isDrawing     = false;
    let lastX = 0, lastY = 0;
    let pendingRender = false;

    let bgColor  = '#ffffff';
    let finalUrl = null;

    let passportFmt = { w: 35, h: 45, name: 'India' };
    let pageSize    = { w: 210, h: 297, name: 'A4' };

    const editCanvas = document.getElementById('editCanvas');
    const editCtx    = editCanvas.getContext('2d');

    // ──────────────────────────────────────────────
    //  Helpers
    // ──────────────────────────────────────────────
    function showPanel(n) {
      document.querySelectorAll('.step-panel').forEach((p, i) =>
        p.classList.toggle('visible', i === n));
      document.querySelectorAll('.step-item').forEach((d, i) => {
        d.classList.toggle('active', i === n);
        d.classList.toggle('done', i < n);
        d.querySelector('.step-circle').textContent = i < n ? '✓' : (i + 1);
      });
    }

    function setLoading(show, msg = 'Processing…') {
      document.getElementById('ldMsg').textContent = msg;
      document.getElementById('loadingOverlay').classList.toggle('show', show);
      if (!show) document.getElementById('ldBar').style.width = '0';
    }
    function setProgress(pct) {
      document.getElementById('ldBar').style.width = Math.round(pct) + '%';
    }

    // ──────────────────────────────────────────────
    //  Step 0: Format + Upload
    // ──────────────────────────────────────────────
    document.querySelectorAll('#formatRow .chip').forEach(c => {
      c.addEventListener('click', () => {
        document.querySelectorAll('#formatRow .chip').forEach(x => x.classList.remove('active'));
        c.classList.add('active');
        passportFmt = { w: parseFloat(c.dataset.w), h: parseFloat(c.dataset.h), name: c.dataset.name };
      });
    });

    function handleFile(file) {
      if (!file || !file.type.startsWith('image/')) return;
      const reader = new FileReader();
      reader.onload = e => {
        const img = new Image();
        img.onload = () => { originalImg = img; initCrop(img); showPanel(1); };
        img.src = e.target.result;
      };
      reader.readAsDataURL(file);
    }

    const uploadZone = document.getElementById('uploadZone');
    document.getElementById('fileInput').addEventListener('change', e => handleFile(e.target.files[0]));
    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'); handleFile(e.dataTransfer.files[0]);
    });

    // ──────────────────────────────────────────────
    //  Step 1: Crop
    // ──────────────────────────────────────────────
    const cropCanvas = document.getElementById('cropCanvas');
    const cropCtx    = cropCanvas.getContext('2d');
    const CW = cropCanvas.width, CH = cropCanvas.height;
    const HS = 10;

    let imgDisp  = null;
    let cropBox  = null;
    let dragMode = null;
    let dragStart = null;

    function initCrop(img) {
      const PAD = 30;
      const sx = (CW - PAD*2) / img.naturalWidth;
      const sy = (CH - PAD*2) / img.naturalHeight;
      const scale = Math.min(sx, sy, 1);
      const dw = img.naturalWidth * scale, dh = img.naturalHeight * scale;
      const dx = (CW - dw) / 2, dy = (CH - dh) / 2;
      imgDisp = { x: dx, y: dy, w: dw, h: dh, scale };

      const ratio = passportFmt.w / passportFmt.h;
      let bh = dh * 0.88, bw = bh * ratio;
      if (bw > dw * 0.88) { bw = dw * 0.88; bh = bw / ratio; }
      cropBox = { x: dx + (dw - bw) / 2, y: dy + (dh - bh) / 2, w: bw, h: bh };
      drawCrop();
    }

    function drawCrop() {
      cropCtx.clearRect(0, 0, CW, CH);
      cropCtx.drawImage(originalImg, imgDisp.x, imgDisp.y, imgDisp.w, imgDisp.h);
      cropCtx.fillStyle = 'rgba(0,0,0,.6)';
      cropCtx.fillRect(0, 0, CW, CH);
      cropCtx.save();
      cropCtx.beginPath();
      cropCtx.rect(cropBox.x, cropBox.y, cropBox.w, cropBox.h);
      cropCtx.clip();
      cropCtx.drawImage(originalImg, imgDisp.x, imgDisp.y, imgDisp.w, imgDisp.h);
      cropCtx.restore();
      cropCtx.strokeStyle = '#ff2200';
      cropCtx.lineWidth = 2;
      cropCtx.strokeRect(cropBox.x, cropBox.y, cropBox.w, cropBox.h);
      // Rule-of-thirds
      cropCtx.strokeStyle = 'rgba(255,255,255,.2)';
      cropCtx.lineWidth = 1;
      cropCtx.setLineDash([4, 4]);
      cropCtx.beginPath();
      const t1x = cropBox.x + cropBox.w/3, t2x = cropBox.x + 2*cropBox.w/3;
      const t1y = cropBox.y + cropBox.h/3, t2y = cropBox.y + 2*cropBox.h/3;
      cropCtx.moveTo(t1x, cropBox.y); cropCtx.lineTo(t1x, cropBox.y + cropBox.h);
      cropCtx.moveTo(t2x, cropBox.y); cropCtx.lineTo(t2x, cropBox.y + cropBox.h);
      cropCtx.moveTo(cropBox.x, t1y); cropCtx.lineTo(cropBox.x + cropBox.w, t1y);
      cropCtx.moveTo(cropBox.x, t2y); cropCtx.lineTo(cropBox.x + cropBox.w, t2y);
      cropCtx.stroke();
      cropCtx.setLineDash([]);
      // Corner handles
      const hpts = cropHandles();
      hpts.forEach(h => { cropCtx.fillStyle = '#ff2200'; cropCtx.fillRect(h.x - HS, h.y - HS, HS*2, HS*2); });
    }

    function cropHandles() {
      const { x, y, w, h } = cropBox;
      return [{ id:'nw',x,y }, { id:'ne',x:x+w,y }, { id:'sw',x,y:y+h }, { id:'se',x:x+w,y:y+h }];
    }
    function hitCropHandle(mx, my) {
      return cropHandles().find(h => Math.abs(mx-h.x) <= HS+2 && Math.abs(my-h.y) <= HS+2);
    }
    function cropCanvasPos(e) {
      const r = cropCanvas.getBoundingClientRect();
      const src = e.touches ? e.touches[0] : e;
      return { x: (src.clientX - r.left) * CW / r.width, y: (src.clientY - r.top) * CH / r.height };
    }

    function cropPointerDown(e) {
      e.preventDefault();
      const p = cropCanvasPos(e);
      const h = hitCropHandle(p.x, p.y);
      if (h) { dragMode = h.id; }
      else if (p.x >= cropBox.x && p.x <= cropBox.x+cropBox.w &&
               p.y >= cropBox.y && p.y <= cropBox.y+cropBox.h) { dragMode = 'move'; }
      else return;
      dragStart = { mx: p.x, my: p.y, box: { ...cropBox } };
    }
    function cropPointerMove(e) {
      if (!dragMode) return;
      e.preventDefault();
      const p = cropCanvasPos(e);
      const dx = p.x - dragStart.mx, dy = p.y - dragStart.my;
      const b = dragStart.box;
      const ratio = passportFmt.w / passportFmt.h;
      const { x: ix, y: iy, w: iw, h: ih } = imgDisp;
      if (dragMode === 'move') {
        cropBox.x = Math.max(ix, Math.min(b.x + dx, ix + iw - cropBox.w));
        cropBox.y = Math.max(iy, Math.min(b.y + dy, iy + ih - cropBox.h));
      } else {
        let nw = b.w, nh, nx = b.x, ny = b.y;
        if      (dragMode === 'se') { nw = b.w + dx; }
        else if (dragMode === 'sw') { nw = b.w - dx; nx = b.x + b.w - nw; }
        else if (dragMode === 'ne') { nw = b.w + dx; }
        else if (dragMode === 'nw') { nw = b.w - dx; nx = b.x + b.w - nw; }
        if (nw < 40) return;
        nh = nw / ratio;
        if (dragMode === 'ne' || dragMode === 'nw') ny = b.y + b.h - nh;
        if (nx < ix) { nw -= ix - nx; nh = nw / ratio; nx = ix; }
        if (ny < iy) { nh -= iy - ny; nw = nh * ratio; ny = iy; }
        if (nx + nw > ix + iw) { nw = ix + iw - nx; nh = nw / ratio; }
        if (ny + nh > iy + ih) { nh = iy + ih - ny; nw = nh * ratio; }
        cropBox = { x: nx, y: ny, w: nw, h: nh };
      }
      drawCrop();
    }
    function cropPointerUp() { dragMode = null; dragStart = null; }

    cropCanvas.addEventListener('mousedown',  cropPointerDown);
    cropCanvas.addEventListener('mousemove',  cropPointerMove);
    cropCanvas.addEventListener('mouseup',    cropPointerUp);
    cropCanvas.addEventListener('mouseleave', cropPointerUp);
    cropCanvas.addEventListener('touchstart', cropPointerDown, { passive: false });
    cropCanvas.addEventListener('touchmove',  cropPointerMove, { passive: false });
    cropCanvas.addEventListener('touchend',   cropPointerUp);

    document.getElementById('applyCropBtn').addEventListener('click', () => {
      const sc = imgDisp.scale;
      const imgX = (cropBox.x - imgDisp.x) / sc;
      const imgY = (cropBox.y - imgDisp.y) / sc;
      const imgW = cropBox.w / sc;
      const imgH = cropBox.h / sc;
      PHOTO_W = 600;
      PHOTO_H = Math.round(PHOTO_W * passportFmt.h / passportFmt.w);

      // Build source canvas (cropped pixels)
      srcCanvas = document.createElement('canvas');
      srcCanvas.width = PHOTO_W; srcCanvas.height = PHOTO_H;
      srcCanvas.getContext('2d').drawImage(originalImg, imgX, imgY, imgW, imgH, 0, 0, PHOTO_W, PHOTO_H);
      croppedUrl = srcCanvas.toDataURL('image/png');

      // Init mask canvas (all white = fully visible)
      maskCanvas = document.createElement('canvas');
      maskCanvas.width = PHOTO_W; maskCanvas.height = PHOTO_H;
      maskCtx = maskCanvas.getContext('2d');
      maskCtx.fillStyle = '#ffffff';
      maskCtx.fillRect(0, 0, PHOTO_W, PHOTO_H);

      // Set edit canvas size
      editCanvas.width  = PHOTO_W;
      editCanvas.height = PHOTO_H;
      editCanvas.style.aspectRatio = `${PHOTO_W} / ${PHOTO_H}`;

      undoStack = [];
      document.getElementById('undoBtn').disabled = true;
      document.getElementById('proceedExportBtn').style.display = 'none';

      renderEdit();
      showPanel(2);
    });

    document.getElementById('b1back').addEventListener('click', () => showPanel(0));

    // ──────────────────────────────────────────────
    //  Edit Canvas Rendering
    // ──────────────────────────────────────────────
    function renderEdit() {
      if (!srcCanvas || !maskCanvas) return;
      // Composite: bg color + (srcCanvas masked by maskCanvas)
      editCtx.fillStyle = bgColor;
      editCtx.fillRect(0, 0, PHOTO_W, PHOTO_H);

      const tmp = document.createElement('canvas');
      tmp.width = PHOTO_W; tmp.height = PHOTO_H;
      const tctx = tmp.getContext('2d');
      tctx.drawImage(srcCanvas, 0, 0);
      tctx.globalCompositeOperation = 'destination-in';
      tctx.drawImage(maskCanvas, 0, 0);

      editCtx.drawImage(tmp, 0, 0);
    }

    function scheduleRender() {
      if (!pendingRender) {
        pendingRender = true;
        requestAnimationFrame(() => { renderEdit(); pendingRender = false; });
      }
    }

    // ──────────────────────────────────────────────
    //  Brush System
    // ──────────────────────────────────────────────
    function getSoftBrush(radius, softness) {
      const key = `${radius.toFixed(1)}_${softness.toFixed(2)}`;
      if (brushCache[key]) return brushCache[key];
      const size = Math.ceil(radius * 2);
      const tmp = document.createElement('canvas');
      tmp.width = size; tmp.height = size;
      const tctx = tmp.getContext('2d');
      if (softness > 0.05) {
        const grad = tctx.createRadialGradient(
          radius, radius, radius * (1 - softness),
          radius, radius, radius
        );
        grad.addColorStop(0, '#fff');
        grad.addColorStop(1, 'rgba(255,255,255,0)');
        tctx.fillStyle = grad;
      } else {
        tctx.fillStyle = '#fff';
      }
      tctx.beginPath();
      tctx.arc(radius, radius, radius, 0, Math.PI * 2);
      tctx.fill();
      if (Object.keys(brushCache).length > 60) {
        delete brushCache[Object.keys(brushCache)[0]];
      }
      brushCache[key] = tmp;
      return tmp;
    }

    function paintBrush(x, y) {
      const radius = brushSize / 2;
      const stamp = getSoftBrush(radius, brushSoftness);
      maskCtx.save();
      maskCtx.globalCompositeOperation = brushMode === 'erase' ? 'destination-out' : 'source-over';
      maskCtx.drawImage(stamp, x - radius, y - radius);
      maskCtx.restore();
    }

    function interpolateBrush(x, y) {
      const dist = Math.hypot(x - lastX, y - lastY);
      const steps = Math.ceil(dist / Math.max(1, brushSize / 4));
      for (let i = 1; i <= steps; i++) {
        paintBrush(
          lastX + (x - lastX) * (i / steps),
          lastY + (y - lastY) * (i / steps)
        );
      }
    }

    function editCanvasPos(e) {
      const r = editCanvas.getBoundingClientRect();
      const src = e.touches ? e.touches[0] : e;
      return {
        x: (src.clientX - r.left) * PHOTO_W / r.width,
        y: (src.clientY - r.top)  * PHOTO_H / r.height
      };
    }

    function pushUndo() {
      if (undoStack.length >= 20) undoStack.shift();
      undoStack.push(maskCtx.getImageData(0, 0, PHOTO_W, PHOTO_H));
      document.getElementById('undoBtn').disabled = false;
    }

    editCanvas.addEventListener('mousedown', e => {
      if (!maskCanvas) return;
      e.preventDefault();
      pushUndo();
      isDrawing = true;
      const p = editCanvasPos(e);
      lastX = p.x; lastY = p.y;
      paintBrush(p.x, p.y);
      scheduleRender();
    });

    window.addEventListener('mousemove', e => {
      const cursor = document.getElementById('brushCursor');
      const onCanvas = e.target === editCanvas || isDrawing;
      if (onCanvas && document.getElementById('panel2').classList.contains('visible')) {
        cursor.style.display = 'block';
        const r = editCanvas.getBoundingClientRect();
        const cssSize = brushSize * (r.width / PHOTO_W);
        cursor.style.width  = cssSize + 'px';
        cursor.style.height = cssSize + 'px';
        cursor.style.left   = e.clientX + 'px';
        cursor.style.top    = e.clientY + 'px';
      } else {
        cursor.style.display = 'none';
      }
      if (!isDrawing) return;
      const p = editCanvasPos(e);
      interpolateBrush(p.x, p.y);
      lastX = p.x; lastY = p.y;
      scheduleRender();
    });

    window.addEventListener('mouseup', () => { isDrawing = false; });
    editCanvas.addEventListener('mouseleave', () => {
      if (!isDrawing) document.getElementById('brushCursor').style.display = 'none';
    });

    editCanvas.addEventListener('touchstart', e => {
      e.preventDefault();
      if (!maskCanvas) return;
      pushUndo();
      isDrawing = true;
      const p = editCanvasPos(e.touches[0]);
      lastX = p.x; lastY = p.y;
      paintBrush(p.x, p.y);
      scheduleRender();
    }, { passive: false });

    window.addEventListener('touchmove', e => {
      if (!isDrawing) return;
      e.preventDefault();
      const p = editCanvasPos(e.touches[0]);
      interpolateBrush(p.x, p.y);
      lastX = p.x; lastY = p.y;
      scheduleRender();
    }, { passive: false });

    window.addEventListener('touchend', () => { isDrawing = false; });

    // ── Brush controls ──
    document.getElementById('modeErase').addEventListener('click', () => {
      brushMode = 'erase';
      document.getElementById('modeErase').classList.add('active');
      document.getElementById('modeRestore').classList.remove('active');
    });
    document.getElementById('modeRestore').addEventListener('click', () => {
      brushMode = 'restore';
      document.getElementById('modeRestore').classList.add('active');
      document.getElementById('modeErase').classList.remove('active');
    });

    document.getElementById('brushSizeSlider').addEventListener('input', e => {
      brushSize = +e.target.value;
      document.getElementById('brushSizeVal').textContent = brushSize;
    });
    document.getElementById('brushSoftSlider').addEventListener('input', e => {
      brushSoftness = e.target.value / 100;
      document.getElementById('brushSoftVal').textContent = e.target.value + '%';
    });

    document.getElementById('undoBtn').addEventListener('click', () => {
      if (undoStack.length === 0) return;
      maskCtx.putImageData(undoStack.pop(), 0, 0);
      document.getElementById('undoBtn').disabled = undoStack.length === 0;
      renderEdit();
    });

    document.getElementById('resetMaskBtn').addEventListener('click', () => {
      if (!maskCanvas) return;
      pushUndo();
      maskCtx.clearRect(0, 0, PHOTO_W, PHOTO_H);
      maskCtx.fillStyle = '#ffffff';
      maskCtx.fillRect(0, 0, PHOTO_W, PHOTO_H);
      renderEdit();
    });

    // ──────────────────────────────────────────────
    //  Step 2: Background Color
    // ──────────────────────────────────────────────
    document.querySelectorAll('#swatches .swatch').forEach(sw => {
      sw.addEventListener('click', () => {
        document.querySelectorAll('#swatches .swatch').forEach(s => s.classList.remove('active'));
        sw.classList.add('active');
        bgColor = sw.dataset.color;
        document.getElementById('bgPicker').value = bgColor;
        renderEdit();
      });
    });

    document.getElementById('bgPicker').addEventListener('input', e => {
      bgColor = e.target.value;
      document.querySelectorAll('#swatches .swatch').forEach(s => s.classList.remove('active'));
      renderEdit();
    });

    // ── AI Background Removal ──
    let bgRemoveLib = null;
    async function loadBgRemoveLib() {
      if (bgRemoveLib) return bgRemoveLib;
      bgRemoveLib = await import('https://esm.sh/@imgly/background-removal@1.4.5');
      return bgRemoveLib;
    }

    document.getElementById('removeBgBtn').addEventListener('click', async () => {
      setLoading(true, 'Loading AI model — first run downloads ~20 MB…');
      setProgress(5);
      try {
        const lib = await loadBgRemoveLib();
        setProgress(20);
        document.getElementById('ldMsg').textContent = 'Removing background…';

        const resultBlob = await lib.removeBackground(croppedUrl, {
          progress: (key, current, total) => {
            if (total > 0) setProgress(20 + (current / total) * 70);
          }
        });

        setProgress(95);
        document.getElementById('ldMsg').textContent = 'Updating mask…';

        // Extract alpha from AI result → update maskCanvas
        const url = URL.createObjectURL(resultBlob);
        const img = new Image();
        img.onload = () => {
          const tmp = document.createElement('canvas');
          tmp.width = PHOTO_W; tmp.height = PHOTO_H;
          const tctx = tmp.getContext('2d');
          tctx.drawImage(img, 0, 0, PHOTO_W, PHOTO_H);
          const aiData   = tctx.getImageData(0, 0, PHOTO_W, PHOTO_H);
          const maskData = maskCtx.getImageData(0, 0, PHOTO_W, PHOTO_H);
          for (let i = 0; i < aiData.data.length; i += 4) {
            // White mask pixel, alpha = AI's alpha channel
            maskData.data[i]     = 255;
            maskData.data[i + 1] = 255;
            maskData.data[i + 2] = 255;
            maskData.data[i + 3] = aiData.data[i + 3];
          }
          maskCtx.putImageData(maskData, 0, 0);
          undoStack = [];
          document.getElementById('undoBtn').disabled = true;
          URL.revokeObjectURL(url);
          renderEdit();
          document.getElementById('proceedExportBtn').style.display = 'block';
          setProgress(100);
          setTimeout(() => setLoading(false), 300);
        };
        img.src = url;
      } catch (err) {
        setLoading(false);
        alert('Background removal failed: ' + (err.message || err));
      }
    });

    document.getElementById('skipBgBtn').addEventListener('click', () => {
      renderEdit();
      finalUrl = editCanvas.toDataURL('image/png');
      document.getElementById('finalImg').src = finalUrl;
      updateGridInfo();
      showPanel(3);
    });

    document.getElementById('proceedExportBtn').addEventListener('click', () => {
      renderEdit();
      finalUrl = editCanvas.toDataURL('image/png');
      document.getElementById('finalImg').src = finalUrl;
      updateGridInfo();
      showPanel(3);
    });

    document.getElementById('b2back').addEventListener('click', () => showPanel(1));

    // ──────────────────────────────────────────────
    //  Step 3: Export
    // ──────────────────────────────────────────────
    document.querySelectorAll('#pageSizeRow .chip').forEach(c => {
      c.addEventListener('click', () => {
        document.querySelectorAll('#pageSizeRow .chip').forEach(x => x.classList.remove('active'));
        c.classList.add('active');
        pageSize = { w: parseFloat(c.dataset.pw), h: parseFloat(c.dataset.ph), name: c.dataset.pn };
        updateGridInfo();
      });
    });

    document.getElementById('photoCount').addEventListener('input', updateGridInfo);

    function calcMaxGrid() {
      const MARGIN = 5, GAP = 3;
      const pw = passportFmt.w, ph = passportFmt.h;
      const cols = Math.max(1, Math.floor((pageSize.w - 2*MARGIN + GAP) / (pw + GAP)));
      const rows = Math.max(1, Math.floor((pageSize.h - 2*MARGIN + GAP) / (ph + GAP)));
      return { cols, rows, maxTotal: cols * rows, MARGIN, GAP };
    }

    function getPhotoCount(maxTotal) {
      const val = parseInt(document.getElementById('photoCount').value);
      if (!val || val <= 0) return maxTotal;
      return Math.min(val, maxTotal);
    }

    function updateGridInfo() {
      const { cols, maxTotal, MARGIN, GAP } = calcMaxGrid();
      const count = getPhotoCount(maxTotal);
      const usedRows = Math.ceil(count / cols);
      const pw = passportFmt.w, ph = passportFmt.h;

      document.getElementById('countHint').textContent = `max ${maxTotal}`;
      document.getElementById('gridPreview').innerHTML =
        `<strong>${count}</strong> photo${count !== 1 ? 's' : ''} · ${cols} per row · ${usedRows} row${usedRows !== 1 ? 's' : ''}<br>
         ${pw}×${ph} mm · ${MARGIN} mm margin · ${GAP} mm gap · ${pageSize.name}`;

      drawLayoutPreview(cols, usedRows, count, MARGIN, GAP, pw, ph);
    }

    function drawLayoutPreview(cols, usedRows, count, margin, gap, pw, ph) {
      const canvas = document.getElementById('layoutCanvas');
      const SCALE  = 0.38;
      canvas.width  = Math.round(pageSize.w * SCALE);
      canvas.height = Math.round(pageSize.h * SCALE);
      const ctx = canvas.getContext('2d');
      ctx.fillStyle = '#ffffff';
      ctx.fillRect(0, 0, canvas.width, canvas.height);
      let n = 0;
      for (let r = 0; r < usedRows; r++) {
        for (let c = 0; c < cols; c++) {
          if (n >= count) break;
          ctx.fillStyle = '#ff2200';
          ctx.fillRect(
            Math.round((margin + c * (pw + gap)) * SCALE),
            Math.round((margin + r * (ph + gap)) * SCALE),
            Math.round(pw * SCALE),
            Math.round(ph * SCALE)
          );
          n++;
        }
      }
    }

    // Returns a data URL with a thin gray rect stroked inside the image bounds
    function applyBorder(url) {
      return new Promise(resolve => {
        const img = new Image();
        img.onload = () => {
          const c = document.createElement('canvas');
          c.width = img.width; c.height = img.height;
          const ctx = c.getContext('2d');
          ctx.drawImage(img, 0, 0);
          ctx.strokeStyle = 'rgba(150,150,150,0.85)';
          ctx.lineWidth = 2;
          ctx.strokeRect(1, 1, img.width - 2, img.height - 2);
          resolve(c.toDataURL('image/png'));
        };
        img.src = url;
      });
    }

    document.getElementById('downloadPngBtn').addEventListener('click', async () => {
      const withBorder = document.getElementById('borderToggle').checked;
      const url = withBorder ? await applyBorder(finalUrl) : finalUrl;
      const a = document.createElement('a');
      a.href = url;
      a.download = 'passport-photo.png';
      a.click();
    });

    document.getElementById('downloadPdfBtn').addEventListener('click', () => {
      const { jsPDF } = window.jspdf;
      const { cols, maxTotal, MARGIN, GAP } = calcMaxGrid();
      const count = getPhotoCount(maxTotal);
      const pw = passportFmt.w, ph = passportFmt.h;
      const addBorder = document.getElementById('borderToggle').checked;

      const doc = new jsPDF({
        orientation: pageSize.h >= pageSize.w ? 'portrait' : 'landscape',
        unit: 'mm',
        format: [pageSize.w, pageSize.h]
      });

      if (addBorder) {
        doc.setDrawColor(150, 150, 150);
        doc.setLineWidth(0.15);
      }

      let n = 0;
      outer: for (let r = 0; ; r++) {
        for (let c = 0; c < cols; c++) {
          if (n >= count) break outer;
          const x = MARGIN + c * (pw + GAP);
          const y = MARGIN + r * (ph + GAP);
          doc.addImage(finalUrl, 'PNG', x, y, pw, ph);
          if (addBorder) doc.rect(x, y, pw, ph);
          n++;
        }
      }
      doc.save('passport-photos.pdf');
    });

    document.getElementById('b3back').addEventListener('click', () => showPanel(2));
    document.getElementById('startOverBtn').addEventListener('click', () => {
      originalImg = croppedUrl = srcCanvas = maskCanvas = maskCtx = finalUrl = null;
      undoStack = []; isDrawing = false;
      document.getElementById('proceedExportBtn').style.display = 'none';
      document.getElementById('photoCount').value = '';
      showPanel(0);
    });
PROCESSING
Removing background…