DEV Community

Xiao Ling
Xiao Ling

Posted on Originally published at dynamsoft.com

How to Generate GS1 Barcodes in JavaScript for Scanner Testing

If you have written a GS1 parser, you need something to test it with.

What you'll build: a browser-side GS1 generator that composes a validated element string from application identifiers, calculates GTIN/SSCC check digits, recommends the symbology a real supply chain would use, renders a test label on an HTML5 canvas, and exports the PNG together with the table of values a scanner should report.

Online Demo

Demo Video

Step 1: Make the AI Table the Model

Everything else is derived from this table, so it is worth getting right first. Each entry needs four things: the code, a name, a length rule, and a way to produce a valid sample.

const AI_LIST = [
  { ai: '01', title: 'GTIN', kind: 'gtin', fixed: 14, group: 'Retail',
    sample: () => withCheckDigit('0' + DEMO_PREFIX + digits(5)) },

  { ai: '10', title: 'BATCH/LOT', kind: 'text', max: 20, group: 'Traceability',
    sample: () => 'LOT-' + digits(5) },

  { ai: '17', title: 'EXPIRATION DATE', kind: 'date', fixed: 6, group: 'Dates',
    sample: () => sampleDate(90, 700) }
];
Enter fullscreen mode Exit fullscreen mode

The fixed/max distinction is not metadata — it is what tells the encoder whether a separator is needed after this element, and it is what lets a parser recover a payload whose separators were stripped.

The sample() function earns its place too. Every AI in the table can produce a value that passes its own validation, so "Randomize data" is always available and a half-built payload is never blocked on typing a plausible SSCC by hand.

One detail that matters more than it sounds: when Randomize data replaces the values, the fields that changed are marked for a moment.

The changed values marked after clicking Randomize data

Random sample values are all digit soup — 00950600403582 and 00950600786614 look equally arbitrary — so without the marker a click reads as "nothing happened", which is exactly the bug report this page got. The marker is a CSS animation on the row that fades out on its own and is removed from the DOM after 1.4 s, and it respects prefers-reduced-motion by dropping the animation while keeping the colour change, because the colour is the information.

Step 2: Calculate Check Digits, Don't Ask for Them

function checkDigit(data) {
  let sum = 0, weight = 3;
  for (let i = data.length - 1; i >= 0; i--) {
    sum += Number(data.charAt(i)) * weight;
    weight = weight === 3 ? 1 : 3;
  }
  return String((10 - (sum % 10)) % 10);
}

function withCheckDigit(dataWithoutCheck) {
  return dataWithoutCheck + checkDigit(dataWithoutCheck);
}
Enter fullscreen mode Exit fullscreen mode

One routine covers GTIN, SSCC, GLN and GSRN, because they all use the same mod-10 scheme. Completing the digit automatically is what makes a generated image a fair test: a scanner that verifies check digits would correctly reject a hand-typed GTIN, and you would spend the afternoon debugging the scanner.

Step 3: Let the Payload Pick the Symbology

Choosing a scenario in the GS1 generator

Twelve symbologies can carry GS1 data, and they are not interchangeable:

Symbology Kind What it can carry
GS1 DataBar Omnidirectional 1D GTIN, full-height retail symbol
GS1 DataBar Truncated 1D GTIN, shorter bars for small packaging
GS1 DataBar Stacked / Stacked Omnidirectional 1D GTIN, split across two rows
GS1 DataBar Limited 1D GTIN starting with 0 or 1, smallest DataBar
GS1 DataBar Expanded / Expanded Stacked 1D GTIN plus up to 74 more characters
GS1 DataMatrix 2D Any element string
GS1 QR Code 2D Any element string, consumer readable
GS1-128 1D Any element string, logistics default
ITF-14 1D GTIN-14 only
EAN-13 1D GTIN-13 only

The recommendation is a small decision function over the payload's AIs, and it is worth having because it encodes the field's own habits:

function decide(rows) {
  const ais = rows.map((row) => row.ai);
  const extra = ais.filter((ai) => ai !== '01');

  if (ais[0] === '00') {
    return { symbology: 'gs1_128',
      reason: 'An SSCC is 18 digits with no GTIN, so a linear GS1-128 is the carrier.' };
  }
  if (!extra.length) {
    return { symbology: /^\d{13}$/.test(rows[0].value) ? 'ean13' : 'databaromni',
      reason: 'A GTIN plus nothing else is exactly what DataBar Omnidirectional encodes.' };
  }
  if (ais.includes('3103') || ais.includes('3922') || ais.includes('30')) {
    return { symbology: 'databarexpandedstacked',
      reason: 'Weight and price make the element string too long for one row, so it stacks.' };
  }
  return { symbology: 'gs1datamatrix',
    reason: ais.length + ' elements is dense enough that a 2D symbol is the right choice.' };
}
Enter fullscreen mode Exit fullscreen mode

Validation is then relative to the chosen symbol, which is the only way it can be meaningful: a GTIN plus a batch is perfectly encodable in DataBar Expanded and completely unencodable in DataBar Omnidirectional.

And every message that diagnoses a problem carries the change that resolves it. "A DataBar, ITF-14 or EAN-13 symbol carries the GTIN and nothing else" is followed by a Switch to GS1 DataMatrix button; a wrong check digit offers the corrected digit; a serial number with no trade item offers Add AI 01; a price in a currency with no quantity offers Add AI 30.

A validation error with its one-click fix

The payload model carries the remedies as data, so the UI never has to guess:

// A value longer than its AI allows, a bad check digit, a symbology that cannot
// carry the payload, a missing partner AI — each one ships the fix with the message.
{ level: 'error', ai: '01', message: 'Check digit should be 2.',
  fix: { kind: 'set-value', ai: '01', value: '09506000134352', label: 'Correct the check digit' } }

{ level: 'error', ai: null,
  message: 'A DataBar, ITF-14 or EAN-13 symbol carries the GTIN and nothing else. 3 extra elements cannot be encoded.',
  fix: { kind: 'set-symbology', symbology: 'gs1datamatrix', label: 'Switch to GS1 DataMatrix' } }
Enter fullscreen mode Exit fullscreen mode

The pairing rules are worth stating outright, because the encoder's own message ("One of more requisite AIs for AI (21) are missing: 01 OR 03 OR 8006") arrives too late to be actionable:

AI needs why
21 serial number 01, 03 or 8006 a serial identifies a unit, so it needs the item it belongs to
393x price in a currency 30, or a 31nn/32nn/35nn/36nn measure a unit price needs the quantity it is a price of

Editing the data elements

Step 4: Feed the Encoder the AI Syntax

bwip-js is the encoder, and its GS1 symbologies accept the bracketed notation directly — the same string GS1 prints under a symbol as the human readable interpretation:

await bwipjs.toCanvas(canvas, {
  bcid: 'gs1datamatrix',
  text: '(01)00950600037152(17)270207(10)LOT-45727(21)SN96136133',
  scale: 6,
  includetext: false
});
Enter fullscreen mode Exit fullscreen mode

BWIPP works out where the FNC1 bytes belong from the AI table: after 10, which has no fixed length, and not after 17, which does. That is a great deal of correctness to get for free, and it is the reason the payload model submits to the encoder as an AI list rather than as a hand-built byte string.

Two symbologies do not take AI syntax, and getting them wrong produces a silently different product:

function toEncoderInput(rows, symbologyId) {
  const gtin = (rows.find((row) => row.ai === '01') || {}).value || '';

  if (symbologyId === 'ean13') {
    // A GTIN-14 with packaging indicator 0 is a GTIN-13 with a leading zero.
    // Any other indicator has no GTIN-13 equivalent, and printing one anyway
    // would produce a symbol that decodes to a different product.
    if (gtin.length === 14 && gtin.charAt(0) !== '0') {
      return { error: 'EAN-13 carries a GTIN-13 only.' };
    }
    return { bcid: 'ean13', text: (gtin.length === 14 ? gtin.slice(1) : gtin).slice(0, 12) };
  }

  if (symbologyId === 'itf14') {
    // Hand over the GTIN as it stands. Trimming it to 13 digits would make the
    // encoder compute a *second* check digit over data that already contained
    // one, and the symbol would decode to a different number.
    return { bcid: 'itf14', text: gtin };
  }

  return { bcid: symbologyId, text: toHRI(rows) };   // the AI syntax form
}
Enter fullscreen mode Exit fullscreen mode

That ITF-14 comment is a bug I shipped and then found. Passing 13 digits to an ITF-14 encoder looks reasonable and is wrong: the encoder appends a check digit, so a GTIN that already ended in one gets a second one computed over it. The generated label decoded cleanly to the wrong number, which is the worst kind of test fixture — it fails a scanner that is working perfectly.

Step 5: Render a Label a Camera Can Actually Read

A symbol floating on a white rectangle is not what a camera sees. Draw the thing that gets scanned: brand, product name, the identifiers in human-readable form, the symbol, and the HRI beneath it.

Generated test label for a serialised healthcare unit

The layout detail that matters is the symbol band: it spans the full label width, because a logistic GS1-128 with five data elements is a lot of bars and squeezing it into a narrow column is how a test label ends up undecodable.

The size detail matters more. When a symbol is too wide for its frame, re-encode it at a smaller whole-number module size; do not scale the finished image:

function step(passesLeft) {
  return attempt().then((canvas) => {
    if (fits(canvas) || scale <= 1 || passesLeft <= 0) return canvas;
    const budget = Math.min(maxWidth / canvas.width, maxHeight / canvas.height);
    const next = Math.max(1, Math.floor(scale * budget));
    if (next >= scale) return canvas;
    scale = next;
    return step(passesLeft - 1);
  });
}
Enter fullscreen mode Exit fullscreen mode

Scaling a linear symbol by a fraction blurs the module edges, and a symbol whose modules are no longer resolvable cannot be decoded however good the scanner is. Keeping the module size an integer number of pixels keeps every bar edge crisp. When even one pixel per module overflows the frame, the honest thing is to say so rather than hand out a weak image:

Rendered GS1-128 · symbol 1010 × 142 px · export 1200 × 800 px
· module size reduced to 2 px to fit the label
Enter fullscreen mode Exit fullscreen mode

For a decoder test, the same page exports the bare symbol with its quiet zone and no label furniture at all.

Step 6: Print the Expected Result

The expected scanner output

This is the part that turns a picture into a test. For the payload that is on screen, the page states:

  • the element string a decoder should return, with | marking each position where an FNC1 byte has to appear;
  • the human readable interpretation;
  • every application identifier with the value the scanner should report, including the check-digit verdict;
  • and the GS1 Digital Link.

A | only appears where a variable-length element needs terminating. Drawing one after every element would be easier to read and wrong: a fixed-length element needs no terminator, and showing one invites the reader to think it does.

Source Code

Get the complete sample project source code on GitHub

Top comments (0)