DEV Community

Arif
Arif

Posted on Fully Autonomous

Pixels aren't millimetres: building accurate photo print layouts in JavaScript

I’m Arif Ali, founder of Foto Carnet Ya. It helps people prepare photos for Spanish DNI, passports and TIE cards. Its free tools let users crop photos to the required dimensions and create print sheets without an account. The geometry in this article comes from that project.

A photo can look correctly sized in a browser and still come out wrong on paper. A preview has pixels; a print layout has physical dimensions. Getting from one to the other requires an explicit resolution, consistent units, and a print workflow that preserves scale.

This tutorial walks through the geometry behind a fixed-aspect photo crop and a sheet containing multiple copies. The examples use a 26 mm wide by 32 mm high photo as a layout input. That input is not a statement about which documents require a photo or whether a particular photo will be accepted.

1. Keep the physical model in millimetres

Store paper dimensions, photo dimensions, margins and gaps in the same unit:

const paper = { widthMm: 100, heightMm: 150 };
const photo = { widthMm: 26, heightMm: 32 };
const marginMm = 4;
const gapMm = 2;
Enter fullscreen mode Exit fullscreen mode

Do not substitute a 4 × 6 inch paper preset for a 10 × 15 cm preset. They are close, but different:

Paper Width Height
10 × 15 cm 100 mm 150 mm
4 × 6 inches 101.6 mm 152.4 mm

The distinction matters when a photo's printed size is part of the output contract. Give each paper size its own preset and label.

2. Convert to pixels at the rendering boundary

There are exactly 25.4 millimetres in an inch. For a chosen raster resolution:

function mmToPx(mm: number, dpi: number): number {
  return Math.round((mm / 25.4) * dpi);
}

const dpi = 300;

console.log(mmToPx(26, dpi));  // 307
console.log(mmToPx(32, dpi));  // 378
console.log(mmToPx(100, dpi)); // 1181
console.log(mmToPx(150, dpi)); // 1772
Enter fullscreen mode Exit fullscreen mode

Here, dpi names the intended print resolution; for a raster image, pixels per inch is the more precise term. It does not describe a printer's individual ink dots.

The resulting dimensions are approximations because pixels are whole numbers. One pixel at 300 pixels per inch represents about 0.085 mm.

Keep the layout in millimetres until drawing. Repeatedly converting back and forth between rounded pixel measurements adds avoidable rounding error.

Increasing the output pixel count also does not recover detail missing from a low-resolution source image.

3. Crop to the target aspect ratio before drawing

Drawing a full camera image directly into a 307 × 378 rectangle can stretch the person's face. Crop the source to the target aspect ratio first.

For positive source dimensions and a positive aspect ratio, the largest matching rectangle inside the source is:

function fitCrop(
  imageWidth: number,
  imageHeight: number,
  aspect: number,
) {
  const width = Math.min(imageWidth, imageHeight * aspect);
  const height = width / aspect;

  return {
    x: (imageWidth - width) / 2,
    y: (imageHeight - height) / 2,
    width,
    height,
  };
}

const crop = fitCrop(3000, 4000, 26 / 32);
// { x: 0, y: approximately 153.85, width: 3000,
//   height: approximately 3692.31 }
Enter fullscreen mode Exit fullscreen mode

This is a centred geometric crop. A portrait editor may let the user move it to position their head properly. Keep those crop coordinates in source-image pixels, and clamp the rectangle inside the image after each movement.

The calculation preserves shape. It does not check head position, expression, lighting or document-specific acceptance rules.

4. Calculate how many copies fit

For one axis, the capacity is:

floor((paper size - 2 × margin + gap) / (photo size + gap))
Enter fullscreen mode Exit fullscreen mode

Why add the gap in the numerator? Because n photos need only n - 1 gaps:

n × photo size + (n - 1) × gap <= paper size - 2 × margin
Enter fullscreen mode Exit fullscreen mode

Rearranging gives the formula above.

function gridCapacity(
  paperWidth: number,
  paperHeight: number,
  photoWidth: number,
  photoHeight: number,
  margin = 4,
  gap = 2,
) {
  const columns = Math.max(
    0,
    Math.floor((paperWidth - 2 * margin + gap) / (photoWidth + gap)),
  );
  const rows = Math.max(
    0,
    Math.floor((paperHeight - 2 * margin + gap) / (photoHeight + gap)),
  );

  return { columns, rows, capacity: columns * rows };
}

gridCapacity(100, 150, 26, 32);
// { columns: 3, rows: 4, capacity: 12 }

gridCapacity(150, 100, 26, 32);
// { columns: 5, rows: 2, capacity: 10 }
Enter fullscreen mode Exit fullscreen mode

Evaluate both paper orientations. Landscape does not necessarily fit more copies. These examples assume positive finite dimensions and non-negative margins and gaps; validate user input before calling the function.

5. Centre the grid independently of the canvas

For a full grid:

const usedWidth = columns * photoWidth + (columns - 1) * gap;
const usedHeight = rows * photoHeight + (rows - 1) * gap;

const left = (paperWidth - usedWidth) / 2;
const top = (paperHeight - usedHeight) / 2;

const x = left + columnIndex * (photoWidth + gap);
const y = top + rowIndex * (photoHeight + gap);
Enter fullscreen mode Exit fullscreen mode

Only convert these positions to pixels when calling drawImage. For a partial grid, explicitly decide whether to centre the grid's bounding box or centre each row separately. Those are different layouts, especially when the last row is incomplete.

6. An exported image still needs a print-size contract

A canvas with 1181 × 1772 pixels does not force every print application to use 100 × 150 mm paper at the intended scale.

The export and printing steps need to agree on:

  • The intended paper dimensions.
  • The raster resolution, including density metadata where the format supports it.
  • Whether the print application applies scaling, cropping or borderless enlargement.

In this project's PNG download path, the renderer creates the canvas at 300 pixels per inch, then a separate export helper writes a PNG pHYs chunk declaring its physical density. Metadata can communicate intent, but a print application may still override it.

For verification, select the matching paper size, avoid automatic enlargement, and measure a printed copy with a ruler. The on-screen preview cannot prove the final physical dimensions.

7. Test the geometry without a browser

The useful boundary is between a pure layout function and a renderer. The layout function returns dimensions and positions; the renderer consumes them.

That makes these properties testable without React, canvas or a printer:

  • Every crop stays inside the source image and retains the target aspect ratio.
  • Every photo stays within the paper's configured margins.
  • No two photos overlap.
  • Impossible copy counts are rejected.
  • Exact metric paper sizes remain distinct from inch-based presets.

For example, for every returned position:

assert.ok(position.x >= margin);
assert.ok(position.y >= margin);
assert.ok(position.x + photoWidth <= paperWidth - margin);
assert.ok(position.y + photoHeight <= paperHeight - margin);
Enter fullscreen mode Exit fullscreen mode

Use a small numerical tolerance when calculations involve floating-point values. These checks protect layout correctness, while a separate physical print check verifies the complete output workflow.

The same separation is useful for labels, badges, tickets and other browser-generated assets that must have a predictable size on paper.


Disclosure: This article was drafted with AI assistance from the author's existing project code. The examples focus on geometry and printing; they do not establish compliance with any identity-document requirements.

Top comments (0)