# Notes on generating QR codes for print: error correction, logo holes and quiet zones

> Practical notes on QR codes that get printed: choosing error correction per medium, sizing a centred logo hole, quiet zones, one path string for SVG, PNG and canvas, and keeping the encoder out of the client bundle.

Canonical: https://sellphotos.online/blog/print-ready-qr-codes-logo
Last updated: 2026-10-30

What we settled on for QR codes that end up on posters, table cards and phone screens.

Every event on sellphotos.online gets a QR code. Photographers print it on posters, notice boards and table cards, or put it on the last slide at a prize-giving. Guests scan it in poor light, at an angle, through glare on a laminated sheet. We generate the codes ourselves with the small `uqr` encoder, then draw them with our own code. These are the decisions that came out of that.

## Pick the error correction level per medium

QR codes carry Reed-Solomon error correction at four levels. Roughly, these are the share of codewords that can be recovered:

| level | recoverable |
|---|---|
| L | ~7% |
| M | ~15% |
| Q | ~25% |
| H | ~30% |

Higher levels need more modules for the same data, so at a fixed print size each module gets smaller. We use three levels, one for each place a code ends up:

- **M** for codes read off a screen. These are the UPI and Pix payment codes on a buyer's order page. A screen is flat, lit and clean.
- **Q** for printed event codes. Paper gets scuffed, laminate catches glare, and cheap printers blur edges.
- **H** when a logo sits in the middle, because the logo hides modules on purpose.

For scale: a 35-character event link such as `https://sellphotos.online/e/AbC1234` comes out as version 3 (29×29 modules) at M, version 4 (33×33) at Q and version 5 (37×37) at H.

## The logo hole

Plenty of generators paste a logo over the finished code and count on error correction to cope. We clear a square of modules first and draw the logo inside it, with a little padding, so no dark modules peek out around a transparent logo:

```ts
// apps/web/app/lib/qr.ts
export const LOGO_RATIO = 0.24;

export function logoHole(matrix: QrMatrix, ratio = LOGO_RATIO): QrHole {
  let size = Math.max(3, Math.round(matrix.size * ratio));
  if (size % 2 !== matrix.size % 2) size += 1; // same parity: exactly centred
  const x = (matrix.size - size) / 2;
  return { x, y: x, size };
}
```

The parity line is easy to miss. A QR code's side is always odd (21 + 4 × (version − 1)), so an even-sized hole can't sit exactly in the middle. `(size − hole) / 2` would land on a half module. Bumping the hole to the same parity keeps it centred on whole modules.

The hole is 24% of the side. At version 5 with H, that's a 9×9 hole in a 37×37 code: 81 of 1,369 modules, about 6%. H can recover about 30%, so most of that margin is left for scuffs, glare and print blur. That's an approximation, since modules map onto codewords unevenly and codewords are interleaved across blocks. Even so, a hole of a few percent behind a ~30% budget leaves real slack.

Also check where the function patterns are. At version 5, the only alignment pattern sits near the bottom-right corner, well clear of a hole spanning modules 14 to 22. From version 7 up, alignment patterns appear in the centre as well, so a long URL with a logo would put one under the logo. Short links matter here.

## Quiet zone, and a caption that stays out of it

The standard (ISO/IEC 18004) asks for four light modules on every side, and we keep all four. Photographers can add the event title as a caption. It goes in its own band below the quiet zone, so the zone stays clear. Long titles are cut at 40 characters, and the font size comes from a rough width estimate (a bold sans is about 0.58 em per glyph), so we never measure text.

## One path string, three outputs

The Share dialog shows a live preview and offers an SVG download, PNGs and a printable table card. They all come from one function that turns the matrix into SVG path data, in module units:

```ts
// square style (simplified): each row's dark runs become one rectangle
for (let y = 0; y < size; y++) {
  let x = 0;
  while (x < size) {
    if (!data[y][x]) { x++; continue; }
    let end = x;
    while (end < size && data[y][end]) end++;
    parts.push(`M${x + margin} ${y + margin}h${end - x}v1h-${end - x}z`);
    x = end;
  }
}
```

Merging runs keeps the path short. The rounded style draws data modules as dots of radius 0.48, slightly under half a module so they stay distinct. It draws the finder patterns as nested rounded squares and fills them with `evenodd`, which punches out their holes.

That one string feeds three renderers:

- the React preview, as `<path d={…}>`
- the SVG download, where the path goes into a standalone document on a white background
- the PNG, drawn on a canvas with `ctx.fill(new Path2D(d), "evenodd")`

We never draw an SVG onto the canvas through an `<img>`. That keeps us clear of canvas tainting and of `img-src` rules for `data:` and `blob:` URLs, and because every output comes from the same geometry, they always match.

A few details on the PNG side:

- **Whole pixels per module.** `modulePx = Math.floor(width / side)`, and the code is centred in what's left over. A fractional module size blurs the edges between modules.
- **A size cap.** The canvas stops at 4096 × 4096 pixels. The printable table card is A6 portrait at about 300 dpi (1240 × 1748 px).
- **Fonts first.** A canvas doesn't wait for web fonts, so we `await document.fonts.load(...)` before drawing the caption. Otherwise the first download comes out in a fallback font.
- **The logo inside the SVG** is embedded as a PNG `data:` URL, so the file stands alone when it goes to a print shop. It's re-encoded to PNG because design and print tools read PNG inside SVG, but not always WebP.

## Colour

Photographers can pick a colour for the modules. We compute the WCAG contrast ratio against white and warn below 4.5. Our own note in the code puts the point where codes may stop scanning at about 3, so the warning fires with a margin to spare.

## Keeping the encoder out of the client bundle

The encoder is small, but most visitors never need it, so we keep it out of the main bundle in two ways.

1. **Lazy loading for sellers.** The Share dialog loads its QR tab on demand, so `uqr` ships only in that chunk.
2. **Server-only for buyers.** The UPI payment code on a buyer's order page is built in the route's loader. React Router's client build drops loaders, and the QR module and `uqr` go with it. The browser receives a side length and a path string, and fills a `Path2D`:

```ts
// apps/web/app/routes/buyer/order.tsx (simplified)
// Loader only: used in a component, this would ship the encoder to every buyer.
function upiHandoff(p: UpiPayment) {
  const uri = upiUri(p);
  const matrix = encodeQr(uri, "M"); // read off a screen
  return { uri, qr: { side: matrix.size + QUIET_ZONE * 2, path: qrModulePath(matrix) } };
}
```

The comment is there for the next person to touch that file. One import from a component would quietly put the encoder back into every buyer's download.

## What we'd check before trusting a design

None of this replaces printing the thing. Before calling a style or logo size done, we'd print it at the size people will actually use, scan it from across a room with an older phone, and try it under a ceiling light reflecting off laminate. The numbers above say how much damage a code should survive. Testing on paper shows whether it does.

The code above lives in a photo-selling app. Each event's QR code opens a gallery where guests find their photos with a selfie. If you're curious what's at the other end of the scan, there's a [sample gallery](https://sellphotos.online/demo?utm_source=blog&utm_medium=article&utm_campaign=print-ready-qr-codes-logo).

## Related

- [Blog](https://sellphotos.online/blog)
- [Pricing](https://sellphotos.online/pricing)
- [Home](https://sellphotos.online/)
