DEV Community

Xiao Ling
Xiao Ling

Posted on Originally published at dynamsoft.com

How to Generate an AAMVA Driver's License Barcode in JavaScript for Scanner Testing

If you are building a driver's license scanner, the hardest part is finding documents to test it with. Real licenses are personal data, so you cannot put them in a test suite or commit them to a repository. And the sample images you can find online cover one or two jurisdictions on a single version of the AAMVA standard — out of 71 jurisdictions and three versions.

So the differences between cards go untested: a card from another jurisdiction has a different field set and jurisdiction code, a v9 card carries different mandated data elements than a v10 card, and an ID card uses a different subfile type than a DL card. One sample image is evidence for one combination out of hundreds, and the gaps only surface as missing or wrong fields once real cards arrive.

You can generate that test data yourself, because the AAMVA barcode is plaintext. It is a byte layout, not a signed blob, so a generator needs no keys, no cards and no special encoder: assemble the payload, encode it as a PDF417 symbol with bwip-js, and draw it on a canvas card. This article does that for all 71 jurisdictions AAMVA assigns an Issuer Identification Number to — 50 US states, the District of Columbia, 5 US territories, 13 Canadian provinces and territories, and 2 Mexican states — and then decodes the result with Dynamsoft Barcode Reader to prove the round trip works. The payoff is a card you can generate on demand, containing no personal information, for any of 71 jurisdictions × 3 versions of the standard × 2 document types — and re-create whenever a test starts failing.

What you'll build: a browser-side generator that takes a jurisdiction and a set of cardholder fields, assembles a specification-compliant AAMVA payload, renders it as a PDF417 symbol on a printable card face and back, and exports the whole sheet as a PNG. Then you'll feed that PNG back into a scanner to confirm the fields survive the trip.

Online Demo

The finished generator runs at codepool/demos/driver-license-generator — no install and no license key, and nothing you type leaves the browser. Read the cards it produces with the matching driver's license scanner demo, which also runs entirely in the browser.

Prerequisites

  • A browser with canvas support. The generator needs no SDK and no license key.
  • A Dynamsoft Capture Vision license if you want to decode the generated card yourself. Get a 30-day free trial license.

Step 1: Lay Out the AAMVA Payload

Everything rests on getting the byte layout right, so start from the specification rather than from a sample card. The header is fixed-width and position-dependent:

Field Size Example Notes
Compliance indicator 1 @ Always @
Data element separator 1 \n Terminates each data element
Record separator 1 \x1e
Segment terminator 1 \r Ends each subfile
File type 5 ANSI Note the trailing space
Issuer Identification Number 6 636014 California
AAMVA version number 2 10 v8 = 2013, v9 = 2016, v10 = 2020
Jurisdiction version number 2 01 Per-jurisdiction revision
Number of entries 2 01 Number of subfiles that follow

Each subfile designator is ten characters — the subfile type, then a four-digit offset and a four-digit length, both zero-padded. Build the payload only after you know the header length, because the offset depends on it:

var LF = '\n';
var RS = '\x1e';
var CR = '\r';

function pad(value, length) {
  var s = String(value);
  while (s.length < length) s = '0' + s;
  return s;
}

function buildPayload(sample, aamvaVersion, jurisdictionVersion) {
  var elements = [];
  function add(code, value) {
    if (value === undefined || value === null || value === '') return;
    elements.push(code + value);
  }

  add('DAQ', sample.licenceNumber);      // licence number
  add('DCS', sample.lastName);           // family name
  add('DDE', 'N');                       // family name truncation
  add('DAC', sample.firstName);          // first name
  add('DDF', 'N');
  add('DAD', sample.middleName);
  add('DDG', 'N');
  add('DCA', sample.vehicleClass);       // jurisdiction vehicle class
  add('DCB', sample.restrictions);
  add('DCD', sample.endorsements);
  add('DBD', mmddyyyy(sample.issueDate)); // issue date
  add('DBB', mmddyyyy(sample.birthDate)); // date of birth
  add('DBA', mmddyyyy(sample.expiryDate));// expiry date
  add('DBC', sample.sexCode);            // 1 = male, 2 = female
  add('DAU', sample.height);             // e.g. '068 in'
  add('DAY', sample.eyeColor);
  add('DAZ', sample.hairColor);
  add('DAG', sample.street);
  add('DAI', sample.city);
  add('DAJ', sample.jurisdictionCode);
  add('DAK', sample.postal);
  add('DCF', sample.documentDiscriminator);
  add('DCG', sample.countryCode);         // USA / CAN / MEX
  add('DDA', 'F');                        // compliance type
  add('DDB', mmddyyyy(sample.issueDate)); // card revision date
  add('DDD', '1');
  add('DAW', sample.weightLbs);

  var subfile = sample.cardType + elements.join(LF) + CR;   // 'DL' or 'ID'

  var entryCount = 1;
  var header = '@' + LF + RS + CR + 'ANSI '
    + sample.iin + aamvaVersion + jurisdictionVersion + pad(entryCount, 2);

  // The subfile starts after the header and all designators.
  var offset = header.length + 10 * entryCount;
  var designator = sample.cardType + pad(offset, 4) + pad(subfile.length, 4);

  return header + designator + subfile;
}

function mmddyyyy(date) {
  return pad(date.getMonth() + 1, 2) + pad(date.getDate(), 2) + date.getFullYear();
}
Enter fullscreen mode Exit fullscreen mode

With a one-subfile payload the offset therefore always evaluates to 31, and subfile.length includes the leading DL and the trailing \r. Add an assertion while you develop — off-by-one errors here produce a payload that looks plausible but decodes to nothing.

Step 2: Cover Every AAMVA Jurisdiction

A generator is only useful if it can produce the jurisdictions you need to test. AAMVA publishes the Issuer Identification Numbers it assigns, which makes the table finite and checkable: 71 entries covering the US, Canada and two Mexican states.

// code, name, iin, country, sample city, postal, licence-number pattern
var JURISDICTIONS = [
  ['CA', 'California', '636014', 'USA', 'Sacramento', '95814', 'A#######'],
  ['TX', 'Texas',    '636015', 'USA', 'Austin',     '78701', '########'],
  ['NY', 'New York', '636001', 'USA', 'Albany',     '12207', '#########'],
  ['ON', 'Ontario',  '636012', 'CAN', 'Toronto',    'M5H 2N1', 'A####-#####'],
  ['BC', 'British Columbia', '636028', 'CAN', 'Victoria', 'V8W 1A1', '########'],
  // ... 66 more
];

function randomFromPattern(pattern) {
  var out = '';
  for (var i = 0; i < pattern.length; i++) {
    var c = pattern[i];
    if (c === '#') out += Math.floor(Math.random() * 10);
    else if (c === 'A') out += String.fromCharCode(65 + Math.floor(Math.random() * 26));
    else out += c;
  }
  return out;
}
Enter fullscreen mode Exit fullscreen mode

The generator page: pick a jurisdiction, a document type and an AAMVA version, then edit the cardholder fields

Each jurisdiction row carries what makes its cards distinguishable: the IIN that goes in the header, the two-letter code that goes in DAJ, a plausible sample city and postal code, and a licence-number pattern. Patterns are illustrative rather than authoritative — the point is to exercise a decoder, not to reproduce any particular issuing authority's formatting.

Step 3: Encode the Payload as PDF417

PDF417 is the symbology North American licenses use, and bwip-js renders it directly to a canvas:

await bwipjs.toCanvas(canvas, {
  bcid: 'pdf417',
  text: payload,
  scale: 8,          // pixels per module
  rowmult: 3,        // row height, in modules
  eclevel: 5,        // Reed-Solomon error correction level
  paddingwidth: 4,
  paddingheight: 4,
  backgroundcolor: 'FFFFFF',
  barcolor: '000000'
});
Enter fullscreen mode Exit fullscreen mode

scale is the setting that decides whether the exported image is scannable. A payload of this size lands around 270–293 characters, which PDF417 lays out in roughly 12 columns; at scale: 8 every module is eight pixels wide, so the symbol survives being downscaled onto a card, exported to PNG and re-uploaded. Rendering at scale: 2 or 3 produces a symbol that looks fine on screen and fails to decode once it has been resized.

The AAMVA payload with its header, subfile designator and data elements

Step 4: Render a Card to Download

Draw the front and the back on one canvas so a single PNG carries both the human-readable fields and the barcode. Two details are worth copying: render the sheet at two device pixels per logical unit so the modules stay large, and stamp a SPECIMEN watermark so the output cannot be mistaken for a real document.

var DPR = 2;
canvas.width = SHEET_W * DPR;
canvas.height = SHEET_H * DPR;
var ctx = canvas.getContext('2d');
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);   // then draw in logical units

function renderSheet(sample, barcodeCanvas) {
  ctx.fillStyle = '#f1f5f9';
  ctx.fillRect(0, 0, SHEET_W, SHEET_H);

  renderFront(ctx, sample);                 // header band, photo box, fields
  renderBack(ctx, sample, barcodeCanvas);   // PDF417 plus a magnetic stripe

  ctx.save();
  ctx.globalAlpha = 0.16;
  ctx.translate(SHEET_W / 2, SHEET_H / 2);
  ctx.rotate(-Math.PI / 6);
  ctx.fillStyle = '#c81e2b';
  ctx.font = 'bold 86px Arial';
  ctx.textAlign = 'center';
  ctx.fillText('SPECIMEN', 0, 0);
  ctx.restore();
}
Enter fullscreen mode Exit fullscreen mode

The generated card: front fields plus the PDF417 barcode on the back

Here is the whole flow in one pass — pick a jurisdiction, randomise the cardholder data, then scroll through what gets generated, front and back:

Step 5: Decode the Generated Card

Now close the loop. The interesting part is how you hand a still image to CaptureVisionRouter. capture() dispatches on the argument type, and the SDK's own image picker passes a DSImageData built from canvas pixels — not a Blob, HTMLImageElement or canvas. Only that shape decodes reliably:

function imageToDsImageData(img) {
  var width = img.naturalWidth;
  var height = img.naturalHeight;
  if (width > 4000) {                       // keep huge photos manageable
    height = Math.round(height * 4000 / width);
    width = 4000;
  }

  var canvas = document.createElement('canvas');
  canvas.width = width;
  canvas.height = height;
  var ctx = canvas.getContext('2d', { willReadFrequently: true });
  ctx.drawImage(img, 0, 0, width, height);

  var data = ctx.getImageData(0, 0, width, height);
  return {
    bytes: new Uint8Array(data.data.buffer, data.data.byteOffset, data.data.length),
    width: width,
    height: height,
    stride: 4 * width,
    format: 10                                // IPF_ABGR_8888 — canvas RGBA order
  };
}

var result = await cvRouter.capture(imageToDsImageData(img), 'ReadDenseBarcodes');

// capture() returns barcodes in `items`; only the streaming receiver uses
// `barcodeResultItems`.
var barcodes = (result.items || []).filter(function (item) {
  return item.type === Dynamsoft.Core.EnumCapturedResultItemType.CRIT_BARCODE;
});

var parsed = await parser.parse(barcodes[0].bytes);
console.log(JSON.parse(parsed.jsonString));
Enter fullscreen mode Exit fullscreen mode

That items versus barcodeResultItems distinction is worth internalising: reading the wrong property makes capture() look broken, returning zero barcodes for every input, when it is working correctly. Log Object.keys(result) before concluding anything.

The quickest way to check a generated card is the driver's license scanner demo, which accepts drag & drop, a file picker and clipboard paste. Download the generated PNG, paste it into the demo with Ctrl+V, and the fields come back:

The scanner's upload zone, which accepts a file, a drop or a pasted image

The scanner returning the fields that were encoded into the generated barcode

Source Code

Get the complete sample project source code on GitHub

To read the cards it produces, open the driver's license scanner demo — no install required — or build the same reader yourself with the driver's license PDF417 tutorial.

Top comments (0)