Hitting an exact KB range in the browser: building an exam photo resizer and age calculator with zero uploads
By Arvind Kumar, Editor, Sarkari Job Selection Every year, millions of Indian candidates fill in government exam forms. Most of these forms reject your photo unless it meets a narrow spec, for example 200Γ230 px and 20β
By Arvind Kumar, Editor, Sarkari Job Selection
Every year, millions of Indian candidates fill in government exam forms. Most of these forms reject your photo unless it meets a narrow spec, for example 200Γ230 px and 20β50 KB for IBPS bank exams. The usual fix is an "online resizer" that uploads someone's face and signature to a random server. We wanted a version that never uploads anything, so we built one with <canvas> and about 150 lines of plain JavaScript. We also built a small age-eligibility calculator, because "Am I over the age limit?" turns out to be a surprisingly sharp date-math problem.
Here is what was tricky about each one.
Part 1: The photo resizer
Specs differ by exam, so keep them as data
const PRESETS = [
{ id: "ibps-photo", w: 200, h: 230, unit: "px", min: 20, max: 50 },
{ id: "ibps-sign", w: 140, h: 60, unit: "px", min: 10, max: 20 },
{ id: "ssc-sign", w: 6, h: 2, unit: "cm", min: 10, max: 20 },
{ id: "upsc-sign", w: 500, h: 500, unit: "px", min: 20, max: 100 },
];
Some notices give sizes in cm, not pixels. We convert using a DPI the user can change (200 by default):
const toPx = (v, unit, dpi) =>
unit === "cm" ? Math.round(v / 2.54 * dpi) : Math.round(v);
Only add a preset once you've checked it in the official notification. Where a notice gives only a KB range and no pixel size, leave the pixel fields blank and let the user enter them. Don't guess.
canvas.toBlob has no "target size" option, so binary-search the quality
JPEG size goes down as quality goes down (not perfectly, but closely enough). So we search for the highest quality that still fits under the max:
// encode(W, H, q) -> Promise<ArrayBuffer>
async function bestUnderMax(encode, W, H, maxBytes) {
let lo = 0.05, hi = 0.95;
const top = await encode(W, H, hi);
if (top.byteLength <= maxBytes) return { buf: top, q: hi };
const floor = await encode(W, H, lo);
if (floor.byteLength > maxBytes) return null; // too many pixels for this budget
let best = { buf: floor, q: lo };
for (let i = 0; i < 8; i++) { // 8 steps is plenty
const mid = (lo + hi) / 2;
const b = await encode(W, H, mid);
if (b.byteLength <= maxBytes) { best = { buf: b, q: mid }; lo = mid; }
else hi = mid;
}
return best;
}
The encoder draws onto a white background, so transparent PNGs don't turn black when saved as JPEG:
function encode(img, W, H, q) {
const c = document.createElement("canvas");
c.width = W; c.height = H;
const x = c.getContext("2d");
x.fillStyle = "#fff"; x.fillRect(0, 0, W, H);
x.imageSmoothingQuality = "high";
x.drawImage(img, 0, 0, W, H); // our real code also handles cover/contain
return new Promise(r => c.toBlob(b => b.arrayBuffer().then(r), "image/jpeg", q));
}
If the user didn't fix the pixel size and quality falls below about 50%, we shrink the image by 15% and try again. A slightly smaller, sharp photo looks better than a blocky one.
The weird case: the file is too small
Some forms need a minimum size too (for example "10β20 KB"), and a 140Γ60 signature can come out at 4 KB. Lowering compression only helps so much. Our fix is to insert a JPEG COM (comment) segment (0xFFFE) right after the SOI marker. Decoders ignore it, and it adds harmless padding bytes:
function padJpeg(buf, minBytes) {
const u = new Uint8Array(buf);
let need = minBytes - u.length;
if (need <= 0) return u;
const segs = [];
while (need > 0) {
const len = Math.min(65533, Math.max(need, 4)); // segment length field max
const s = new Uint8Array(len + 2);
s[0] = 0xFF; s[1] = 0xFE; s[2] = (len >> 8) & 255; s[3] = len & 255;
s.fill(32, 4);
segs.push(s); need -= s.length;
}
const out = new Uint8Array(u.length + segs.reduce((n, s) => n + s.length, 0));
out.set(u.subarray(0, 2), 0); // SOI
let off = 2;
for (const s of segs) { out.set(s, off); off += s.length; }
out.set(u.subarray(2), off);
return out;
}
We tell the user when we've padded a file, so nothing happens silently.
Privacy for free
FileReader β Image β canvas β Blob β URL.createObjectURL. The photo never leaves the device, there's no backend to secure, and the tool works on cheap phones. Remember to call URL.revokeObjectURL on the old URL each time you create a new one.
Part 2: The age-limit calculator
A notice says something like "18β27 years as on 01-08-2026". The SSC CHSL 2026 notice spells it out: the candidate must be born not before 02-08-1999 and not later than 01-08-2008. That means:
- you must have reached the minimum age on the cut-off date, and
- you must not have reached the maximum age (so "27" really means "below 27").
const eligible = (age, min, max) => age.y >= min && age.y < max;
Don't use Date in local time
Timezones and DST will quietly shift dates by one day. Parse YYYY-MM-DD yourself, do the math in UTC, and reject impossible dates like 31-02:
function parse(s) {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s || "");
if (!m) return null;
const [y, mo, d] = [+m[1], +m[2], +m[3]];
const dt = new Date(Date.UTC(y, mo - 1, d));
return dt.getUTCMonth() === mo - 1 ? { y, m: mo, d } : null;
}
const daysIn = (y, m) => new Date(Date.UTC(y, m, 0)).getUTCDate();
function age(b, c) {
let months = (c.y - b.y) * 12 + (c.m - b.m);
if (c.d < b.d) months--;
const ay = b.y + Math.floor((b.m - 1 + months) / 12);
const am = (b.m - 1 + months) % 12 + 1;
const ad = Math.min(b.d, daysIn(ay, am)); // 29 Feb / 31st clamp
const days = Math.round(
(Date.UTC(c.y, c.m - 1, c.d) - Date.UTC(ay, am - 1, ad)) / 864e5);
return { y: Math.floor(months / 12), m: months % 12, d: days };
}
Test the boundaries the notice gives you
The notice's own dates make good test cases:
| DOB | Cut-off | Age | 18β27? |
|---|---|---|---|
| 02-08-1999 | 01-08-2026 | 26y 11m 30d | β |
| 01-08-1999 | 01-08-2026 | 27y 0m 0d | β (reached 27) |
| 01-08-2008 | 01-08-2026 | 18y 0m 0d | β |
| 02-08-2008 | 01-08-2026 | 17y 11m 30d | β |
| 29-02-2000 | 01-03-2026 | 26y 0m 1d | β |
Category relaxation is just max + years (for SSC CGL/CHSL 2026: OBC +3, SC/ST +5). Rules vary by exam, though, so we link every quick preset to its official PDF and tell users to confirm there.
Takeaways
- Size targets β binary search on quality, then shrink the pixels if quality gets too low.
- Minimum-size targets β pad with a JPEG COM segment, and tell the user you did.
- Date eligibility β integer y/m/d math in UTC, and use the notice's own boundary dates as unit tests.
- Keep specs as data with a source link. Leave out anything you can't verify.
Both tools are free to use (no signup, nothing uploaded): the exam photo & signature resizer and the govt job age calculator. If you know a quality-vs-size heuristic that beats plain binary search, I'd love to hear it in the comments.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.