DEV Community

Silkline
Silkline

Posted on AI-assisted

Printing Data Matrix codes at an exact size: modules, DPI and PNG pHYs

"Make the code 12 mm wide" sounds simple. Then the label goes through a 203 dpi thermal printer, the edges come out ragged, and the scanner struggles. The problem is rarely the barcode library. It's the step from millimetres to printer dots.

I recently built a browser Data Matrix generator for labels, and getting the physical size right was most of the work. This post covers the maths and the file formats, then GS1 input and a read-back check.

Modules and dots

A Data Matrix is a grid of square modules, for example 24×24, plus a quiet zone around it. If you want the code (without the quiet zone) to be 12 mm wide, each module is 12 / 24 = 0.5 mm.

A printer can't print 0.5 mm, though. It prints dots. At 203 dpi a dot is 25.4 / 203 ≈ 0.125 mm, so 0.5 mm is 3.996 dots. Print that as-is and each module edge gets rounded to a dot somewhere along the way, so modules come out at uneven widths. The fix is to round to a whole number of dots per module first, and derive every size from that:

export function resolvePrintSize(request: PrintRequest, { columns, rows, quietZone }: Geometry, referenceColumns = columns): PrintSize {
  const wantedModuleMm = request.basis === 'module' ? request.valueMm : request.valueMm / referenceColumns;
  const wantedDots = (wantedModuleMm * request.dpi) / MM_PER_INCH;
  const dotsPerModule = Math.max(1, Math.round(wantedDots));
  const moduleMm = (dotsPerModule * MM_PER_INCH) / request.dpi;
  return {
    dotsPerModule,
    moduleMm,
    totalWidthMm: (columns + 2 * quietZone) * moduleMm,
    pixelWidth: (columns + 2 * quietZone) * dotsPerModule,
    // ...heights, code size without quiet zone, and a flag if the module was raised to one dot
  };
}
Enter fullscreen mode Exit fullscreen mode

For the 12 mm example at 203 dpi that's 4 dots, a 0.5005 mm module and a 12.01 mm code. At 300 dpi the same request becomes 6 dots per module and a 12.19 mm code: slightly bigger than asked, but every module is identical. The tool prints the actual module and code size, so the difference isn't hidden.

referenceColumns is for batches: codes with different data can get different symbol sizes, and passing the widest keeps one module size across the batch.

Getting the module grid out of bwip-js

bwip-js (a JavaScript port of BWIPP) does the encoding. I wanted the raw module grid rather than a canvas, so one matrix could feed SVG, PNG and PDF. bwip-js accepts a custom drawing object, so I pass one whose methods do nothing except record the outlines it traces:

polygon: (points: Point[]) => outlines.push(points.map(([x, y]) => [x, y] as Point)),
end: () => ({ outlines, width, height }),
Enter fullscreen mode Exit fullscreen mode

At scale 1, one module is one unit, so testing the centre of each cell, (column + 0.5, row + 0.5), against those outlines with an even-odd rule gives the dark and light modules.

For plain text there's one encoding decision. Latin-1 text goes in as single bytes. Anything else is encoded as UTF-8 behind ECI 000026, so readers know which code page the bytes are in.

SVG: a viewBox in modules, a size in millimetres

SVG makes physical size easy. The viewBox is in modules, and width and height are written in mm:

`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" ${size} shape-rendering="crispEdges" role="img">`
Enter fullscreen mode Exit fullscreen mode

In print mode size is something like width="14.014mm" height="14.014mm": the 12 mm example at 203 dpi plus a two-module quiet zone on each side. Dark modules are merged into horizontal runs per row in one <path>. The background rectangle is optional, because laser and cutting software read it as one more shape to burn.

PNG: one pixel per dot, plus pHYs

A PNG has no physical size unless you write one. The pHYs chunk stores pixels per unit on each axis, with unit 1 meaning metres. I write the PNG myself (fflate for the zlib stream, plus a small CRC and chunk writer), so the bytes are the same in every browser and no canvas is involved:

/** pHYs chunk: pixels per metre in both directions, so print software and image editors know the physical size. */
function pixelDensity(dpi: number): Uint8Array {
  const data = new Uint8Array(9);
  const perMetre = Math.round(dpi / 0.0254);
  const view = new DataView(data.buffer);
  view.setUint32(0, perMetre);
  view.setUint32(4, perMetre);
  data[8] = 1;
  return chunk('pHYs', data);
}
Enter fullscreen mode Exit fullscreen mode

300 dpi becomes 11811 pixels per metre. The chunk goes after IHDR and before IDAT, as the PNG spec requires. In print mode each module is exactly dotsPerModule pixels, so one image pixel is one printer dot and nothing gets resampled on the way to the printer.

The PNG is one RGBA buffer in memory, so sizes over 10,000 px a side or 40 megapixels are refused in favour of SVG or PDF.

PDF: the page is the code

For PDF I use jsPDF with unit: 'mm' and format: [totalWidthMm, totalHeightMm], so the page is exactly the code plus its quiet zone. Modules are vector rectangles from the same row runs as the SVG. Print at 100%, not "fit to page", and it's the size you asked for.

GS1: brackets in, FNC1 out

GS1 Data Matrix carries element strings such as (01)09506000134352(17)281231(10)LOT42. Each Application Identifier (AI) has a format, and some, like the GTIN in (01), end in a mod-10 check digit (weights 3 and 1 alternating from the right). The tool recomputes it and says what the digit should be.

For the common AIs, values are checked against the regex GS1 publishes, so a bad date gets a specific message such as "13 isn't a month" instead of a terse linter error. Less common AIs are still checked by BWIPP's own GS1 linter inside bwip-js.

The brackets are not encoded. The encoder (gs1datamatrix in bwip-js) adds FNC1 at the start and the group separators itself. So the most important cleanup is removing what users paste from other tools: <GS>, {GS}, [FNC1], \x1d, a leading ]d2. Left in, they'd be encoded as data, and you'd get a wrong code that still scans.

Read it back before offering it

The last step is a self-check. After encoding, a separate decoder reads the drawn modules the way a scanner would: strip the finder and timing patterns, walk the data modules in the placement order from ISO/IEC 16022, decode the codewords. Then it compares the result with what was asked for:

export function readBack(matrix: ModuleMatrix, request: EncodeRequest): ScannedSymbol | null {
  try {
    const scanned = scanMatrix(matrix);
    const data = request.mode === 'gs1' ? scanned.text.replaceAll('\u001d', '') : scanned.text;
    return data === expectedData(request) ? scanned : null;
  } catch {
    return null;
  }
}
Enter fullscreen mode Exit fullscreen mode

If it doesn't match, the code isn't offered at all. The decoder is written independently of the encoder. In the test suite, the same symbols in every size also go through zxing-wasm, and optionally libdmtx, as independent decoders.

Summary

  • Round to a whole number of printer dots per module, then derive every size from that.
  • Write SVG with a viewBox in modules and width and height in mm.
  • Give PNGs a pHYs chunk (pixels per metre, unit 1) and make one pixel one printer dot.
  • Make the PDF page exactly the code plus quiet zone, and print at 100%.
  • For GS1, validate AIs and check digits, and strip any separators the user typed.
  • Decode what you drew and compare it with the input before you hand the file over.

If you need a Data Matrix at a real size in mm, the generator is free for single codes at data-matrix-generator.com, and it runs entirely in your browser.

Top comments (0)