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
Background Color
custom color
AI Removal
Automatically remove the background. Model (~20 MB) cached after first run.
or
Final 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);
});