Document photos are specified in millimetres. Your canvas works in pixels.
Document photos are specified in millimetres. Your canvas works in pixels — and that's only the first of three conversions If you have ever built anything that produces an ID photo, passport photo or visa photo, you hav
Document photos are specified in millimetres. Your canvas works in pixels — and that's only the first of three conversions
If you have ever built anything that produces an ID photo, passport photo or visa photo, you have met a specification that looks like this:
35 × 45 mm, head 32–36 mm from chin to crown, plain light background,
JPEG, 240 KB max
That single line mixes three incompatible units: millimetres (physical), pixels (digital) and bytes (compression). The browser only understands the last two. This post walks through the conversions, the traps, and the code I ended up with.
1. mm → px needs DPI, and DPI is a print concept
The formula is boring:
const px = mm => Math.round((mm / 25.4) * dpi);
What matters is which DPI you pick, because the same 35 × 45 mm photo is three different files:
| Spec | At 300 DPI | At 600 DPI |
|---|---|---|
| US passport, 2 × 2 in | 600 × 600 | 1200 × 1200 |
| UK / Schengen, 35 × 45 mm | 413 × 531 | 827 × 1063 |
| India passport, 35 × 45 mm | 413 × 531 | 827 × 1063 |
| China visa, 33 × 48 mm | 390 × 567 | 780 × 1134 |
Here is the part people get wrong: for an online upload, the portal does not read your DPI tag. It reads pixels and file size. For a print sheet, the kiosk reads pixels per inch on paper. So one export cannot satisfy both, and a tool that outputs a single file is quietly wrong for half its users.
The US 2 × 2 inch passport photo is a good reference case, and the dimensions are published: US passport photo requirements. The UK 35 × 45 mm spec is the one most of Europe and much of Asia copies — see UK passport photo requirements, and the plain 35 × 45 mm size page for the pixel variants.
2. The EXIF orientation trap
A phone photo is often stored as a landscape buffer plus an orientation tag saying "rotate 90°". Draw that buffer straight into a canvas and you get a sideways person — and a sideways face crop, which fails every automated check.
Modern browsers apply EXIF orientation when decoding <img>, but not everywhere, and not identically. Be explicit:
async function decode(file) {
const bitmap = await createImageBitmap(file, {
imageOrientation: 'from-image', // honor EXIF
});
return bitmap;
}
If you fall back to new Image() + drawImage, test on a real phone photo taken in landscape orientation. It is the single most common "works on my machine" bug in this space.
3. Head size is a ratio, not a measurement
"Head 32–36 mm" only means something relative to the frame. In practice every spec is a ratio:
- US: head height must be 50–69% of the total photo height
- UK: head height 29–34 mm within a 35 mm width, i.e. roughly 65–75%
You cannot measure the head without detecting the face box. Options, from worst to best:
- Ask the user to drag a crop box. Zero dependencies, works everywhere, and users who have taken a photo at home usually know where their head is.
- Use the face detector that ships with some browsers (
FaceDetector). Present on Chrome/Android only, and silently missing elsewhere. - Run a small model client-side. Best accuracy, worst bundle size.
Whatever you pick, store the numbers, not the picture: {faceTop, faceBottom, imageHeight} is debuggable in a log line. A rendered crop is not.
4. Hitting a KB budget
Several portals cap file size (the DV lottery is a 240 KB classic). Quality is the only lever you should touch — resizing to hit a byte count silently changes the physical size, which is the one thing the spec cares about.
async function encodeUnder(canvas, maxKB, type = 'image/jpeg') {
let lo = 0.4, hi = 0.95, best = null;
for (let i = 0; i < 6; i++) {
const q = (lo + hi) / 2;
const blob = await new Promise(r => canvas.toBlob(r, type, q));
if (blob.size / 1024 <= maxKB) { best = blob; lo = q; }
else hi = q;
}
return best; // six encodes ≈ 30 ms for a 600×600 canvas
}
Six encodes is enough resolution for the quality range that matters, and it avoids the classic "encode in a loop with step 0.05" that takes a second on a mid-range phone.
5. What I would do differently next time
- Keep the spec as data (
{wMm, hMm, dpi, maxKB, headRatio}), not as a chain ofif (country === 'uk'). - Render a 4 × 6 inch print sheet from the same canvas, because most people eventually want to print — one $4.99 unlock that covers a passport photo and a visa photo is what makes the pricing work.
- Never let a filter, beauty mode or generative fill touch a US passport photo. The State Department explicitly forbids it; cropping and sizing are fine, "fixing" the face is not.
The three units are the whole problem. Once mm, px and bytes live in one object, the rest of the pipeline is just canvas calls.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.