How to Generate MRZ Code Programmatically with JavaScript and HTML5 Canvas

Testing an MRZ reader has a data problem before it has a code problem. Passports are not public, a photographed specimen tells you nothing about what the reader was supposed to return, and a scan you found somewhere has no ground truth attached to it.

So the images get generated. This article builds the generator behind the online MRZ demo — plain JavaScript, no SDK, no server. It writes the two or three fixed-width lines a machine-readable zone is made of, computes every check digit the layout defines, and draws the result on a canvas as a passport, ID card or visa page. The last two steps turn one document into a hundred-image dataset with ground truth, and read the images back with an MRZ reader.

What you’ll build: a browser-only MRZ generator for the five ICAO 9303 layouts — a 7-3-1 check-digit function, one small class per layout, a canvas renderer that draws the data page around the zone, and a seeded dataset mode that exports test images with annotations.

Watch the Generator in Action

The clip runs the page end to end: randomized sample data, a passport page rendered from it, the same record re-cut as a TD1 ID card with three 30-character lines, then the dataset mode building twelve degraded samples and showing the expected fields for one of them.

Key Takeaways

  • An MRZ is fixed-width text whose field positions are defined per layout: the same data written as TD1 and as TD3 produces two different strings, and a reader expecting one cannot use the other.
  • Every check digit uses one function: map each character to a number, multiply by the repeating weights 7, 3, 1, sum, take modulo 10.
  • The composite digit is the same function over four fields with their check digits concatenated — which is why it catches a wrong date that the individual digits missed.
  • OCR-B at a fixed character pitch is what makes a canvas-rendered MRZ readable; the demo ships the font rather than hoping the system has it.
  • A seed plus per-image degradation values is what turns a folder of test images into a benchmark you can re-run and compare.
  • Generation needs no license key. Only reading the images back with an SDK does.

Common Developer Questions

How do I generate an MRZ string in JavaScript?

Write one function for the 7-3-1 check digit, then one small class per layout that pads each field to its width and concatenates them in the order ICAO 9303 defines. Nothing in the process needs a library, a server or a build step — it is string arithmetic followed by a fillText call.

Why is my MRZ check digit always wrong?

Three causes cover almost every case: the field was not padded to its full width before the check digit was computed, the weights were not restarted at 7 for that field, or a letter was passed through as its ASCII code instead of its MRZ value (A = 10, not 65). A character outside A–Z, 0–9 and the filler silently becomes 0 in most implementations, which produces a plausible but wrong digit.

How do I make MRZ test images that a reader can actually scan?

Draw the zone in OCR-B, at the character height and line pitch the layout defines, and leave the area around the band clear — a reader needs the zone to be the only fixed-pitch text in view. A monospace fallback works for your eyes and fails for a reader, because its glyph widths are not OCR-B’s.

What Is a Machine Readable Zone (MRZ)?

A Machine Readable Zone is the block of fixed-width text at the bottom of a passport, ID card or visa, defined by ICAO Document 9303. It repeats what the visual zone says — name, nationality, document number, date of birth, sex, expiry date — in a form an OCR engine can read without knowing the language or the layout of the page. Five layouts exist:

  • TD3 — 2 lines of 44 characters. Passport booklets.
  • TD2 — 2 lines of 36 characters. ID-2 cards and some travel documents.
  • TD1 — 3 lines of 30 characters. Credit-card-sized ID cards.
  • MRV-A — 2 lines of 44 characters. Full-page visa stickers.
  • MRV-B — 2 lines of 36 characters. Smaller visa stickers.

Every field sits at a fixed character position, and the layouts are not interchangeable: TD3 puts the name on line 1 and the document number on line 2, while TD1 puts the document number on line 1 and the name on line 3.

Prerequisites

  • A modern browser. The generator is plain JavaScript: no npm install, no bundler, no build step.
  • The OCR-B font shipped with the sample (fonts/ocrbfont.ttf), or any OCR-B you are licensed to embed.
  • For Step 6 only, a Dynamsoft Capture Vision license key — the generator itself needs none. Get a 30-day free trial license.

Step 1: Write the 7-3-1 check digit

The rest of the generator is plumbing. The part that has to be right is the check digit, and ICAO 9303 defines exactly one algorithm for all of it:

// mrz-generator.js
static calculateCheckDigit(data) {
    const weights = [7, 3, 1];
    const charValues = {
        '<': 0, '0': 0, '1': 1, '2': 2, '3': 3, '4': 4, '5': 5, '6': 6, '7': 7, '8': 8, '9': 9,
        'A': 10, 'B': 11, 'C': 12, 'D': 13, 'E': 14, 'F': 15, 'G': 16, 'H': 17, 'I': 18,
        'J': 19, 'K': 20, 'L': 21, 'M': 22, 'N': 23, 'O': 24, 'P': 25, 'Q': 26, 'R': 27,
        'S': 28, 'T': 29, 'U': 30, 'V': 31, 'W': 32, 'X': 33, 'Y': 34, 'Z': 35
    };

    let total = 0;
    for (let i = 0; i < data.length; i++) {
        const value = charValues[data[i].toUpperCase()] ?? 0;
        total += value * weights[i % 3];
    }
    return (total % 10).toString();
}

Take the passport in the demo: document number 422016774. Digits keep their own value, and the weights repeat from 7 at the first character of the field:

Character 4 2 2 0 1 6 7 7 4
Value 4 2 2 0 1 6 7 7 4
Weight 7 3 1 7 3 1 7 3 1
Product 28 6 2 0 3 6 49 21 4

The products add to 119, and 119 modulo 10 is 9 — the character at position 10 of the lower line.

The composite check digit is the same function, run over a longer input: the document number with its check digit, the birth date with its check digit, the expiry date with its check digit, and the optional-data field with its check digit. For the example that is 39 characters, and it returns 0, the last character of line 2. Nationality and sex are deliberately left out, so a wrong nationality can pass a composite check.

One trap worth naming, because plenty of implementations get it wrong: TD1 and TD2 have no check digit on the optional-data field. In TD1 the composite is the last character of line 2; in TD2 the optional data runs to position 35 and position 36 is the composite. TD3 is the only layout in this set that carries an optional-data check digit and a composite, at positions 43 and 44.

Step 2: Assemble the five MRZ layouts

mrz-generator.js is about 340 lines. A base class holds the static helpers — the check digit from Step 1, padding, name formatting, the country-code exception — and each layout subclasses it with its own generate():

class TD3CodeGenerator extends MRZGenerator {
    generate() {
        // Line 1: document type (2) + country (3) + names (39)
        const docType = MRZGenerator.pad(this.documentType, 2);
        const country = MRZGenerator.pad(this.countryCode, 3);
        const names = this.surname + '<<' + MRZGenerator.formatNames(this.givenNames);
        const line1 = docType + country + MRZGenerator.pad(names, 39);

        // Line 2: number, check, nationality, birth, check, sex, expiry, check,
        // optional data (14), check, composite
        const docNum = MRZGenerator.pad(this.documentNumber, 9);
        const docNumCheck = MRZGenerator.calculateCheckDigit(docNum);

        const nat = MRZGenerator.pad(this.nationality, 3);
        const birth = MRZGenerator.pad(this.birthDate, 6);
        const birthCheck = MRZGenerator.calculateCheckDigit(birth);

        const sexField = MRZGenerator.pad(this.sex, 1);
        const expiry = MRZGenerator.pad(this.expiryDate, 6);
        const expiryCheck = MRZGenerator.calculateCheckDigit(expiry);

        const optional = MRZGenerator.pad(this.optionalData, 14);
        const optionalCheck = MRZGenerator.calculateCheckDigit(optional);

        // The composite runs over the four fields with their own digits attached
        const compositeData = docNum + docNumCheck + birth + birthCheck +
            expiry + expiryCheck + optional + optionalCheck;
        const compositeCheck = MRZGenerator.calculateCheckDigit(compositeData);

        const line2 = docNum + docNumCheck + nat + birth + birthCheck + sexField +
            expiry + expiryCheck + optional + optionalCheck + compositeCheck;

        return line1 + '\n' + line2;
    }
}

Three helpers do the quiet work. pad() appends the filler < until the field reaches its width and cuts anything longer, which is why a 30-character surname loses its tail on a TD1. formatNames() uppercases and turns spaces into single fillers, so CALLIE ANN becomes CALLIE<ANN. And normalizeCountryCode() handles the one code that is not ISO 3166: ICAO prints Germany as D, so DEU is rewritten to D before padding, and the field reads D<<.

Step 3: Fill the form with sample data

The demo needs plausible input on first load, and “plausible” here means dates that make sense: a holder between 18 and 80, an expiry in the next decade, and a document number of the right shape.

function randomMRZData() {
    const currentYear = new Date().getFullYear();
    return {
        'Surname': randomString(randomIntFromInterval(3, 7)),
        'Given Name': randomString(randomIntFromInterval(3, 7)),
        'Nationality': VALID_COUNTRY_CODES[Math.floor(Math.random() * VALID_COUNTRY_CODES.length)],
        'Sex': Math.random() < 0.5 ? 'M' : 'F',
        'Document Number': randomString(9, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'),
        'Birth Date': formatDate(randomDate(currentYear - 80, currentYear - 18)),
        'Expiry Date': formatDate(randomDate(currentYear + 1, currentYear + 10))
    };
}

formatDate() is the only date logic that matters: YYMMDD, no century, no separators. A reader that assumes 19xx will read a 2003 birth date as 1903, and that is the reader’s convention to document, not something the string can settle.

Step 4: Draw the data page on a canvas

A string is not a test image. renderDocument() draws the whole data page on a canvas — header band, photo box, field columns, signature line, security pattern — and then writes the MRZ in OCR-B at a fixed pitch:

ctx.font = mrz.fontSize + 'px "OCR-B", monospace';
lines.forEach((line, index) => {
    ctx.fillText(line, mrz.x, mrz.firstBaseline + index * mrz.lineHeight);
});

Two details decide whether a reader can do anything with the result.

The font has to be OCR-B. The page ships fonts/ocrbfont.ttf and declares it with @font-face; before the first render it awaits document.fonts.load('16px OCR-B'). A fallback monospace looks fine to a human and changes every glyph’s advance width, which is exactly what a fixed-pitch reader is measuring.

The pitch has to be measured, not assumed. mrzLayout() measures a 44-character line with measureText(), sizes the band around it and returns the band’s rectangle. The same rectangle is handed back to the caller as the ground-truth quad, so the annotation marks where the renderer actually drew the zone instead of where it guessed it would.

Synthetic passport page rendered by the generator

The portrait is drawn from primitives too — skin, hair, eyes, shoulders composited per record — so no photograph of anyone is involved, and the same record always produces the same face.

Step 5: Turn one document into a seeded dataset

One image proves the writer works. A benchmark needs a hundred images with known difficulty and known answers, which is what stress.js does on top of the same renderer:

function makeRng(seed) {
    let a = seed >>> 0;
    return function () {
        a = (a + 0x6d2b79f5) >>> 0;
        let t = a;
        t = Math.imul(t ^ (t >>> 15), t | 1);
        t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
        return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
    };
}

Every random choice in a sample comes from a stream derived from the seed and the sample index, so the same seed rebuilds the same set, and raising the sample count never changes the images that were already there. On top of that the engine applies twelve degradations — rotation, perspective, blur, motion blur, contrast, glare, uneven lighting, noise, JPEG, inversion, output scale and a frame crop — each drawn from its own min/max range, then writes the answer key:

{
  "format": "mrz-benchmark/1.0",
  "images": [{
    "file": "mrz-stress-1a2b3c4d-001.png",
    "width": 900,
    "height": 640,
    "degradations": { "rotate": -6.2, "blur": 1.1, "jpeg": 62 },
    "mrz": {
      "format": "TD3",
      "issuingState": "USA",
      "text": "P<USANELSON<<CALLIE<<<<<<<<<<<<<<<<<<<<<<<<<\n4220167749USA8810171F2712239572615374<585990",
      "fields": {
        "documentNumber": "422016774", "surname": "NELSON", "givenNames": "CALLIE",
        "nationality": "USA", "birthDate": "881017", "sex": "F", "expiryDate": "271223"
      },
      "checkDigits": {
        "documentNumber": "9", "birthDate": "1", "expiryDate": "9", "composite": "0"
      },
      "points": [[60, 470], [408, 470], [408, 516], [60, 516]]
    }
  }]
}

The ZIP holds the PNGs and that file. The frame crop is clamped so it can never cut the MRZ band — a partial zone is undecodable by any reader, so counting it as a miss would measure nothing. What the crop actually kept is recorded beside what was requested.

Step 6: Read the generated images back

The generator is deliberately SDK-free, so the reading half is a separate step. With Dynamsoft Capture Vision in the browser, MRZ needs a template: 3.x has no built-in MRZ preset, so the models and the text-line rules are declared in a settings file, and the two models are appended before that file is loaded.

<script src="https://cdn.jsdelivr.net/npm/dynamsoft-capture-vision-bundle@3.6.5000/dist/dcv.bundle.min.js"></script>
<script>
async function readMrz(blob) {
    await Dynamsoft.License.LicenseManager.initLicense("LICENSE-KEY", true);
    await Dynamsoft.Core.CoreModule.loadWasm(["DLR"]);

    // The MRZ models are not part of the bundle; load them before the template needs them.
    await Dynamsoft.CVR.CaptureVisionRouter.appendDLModelBuffer([
        "MRZCharRecognition", "MRZTextLineRecognition"
    ]);

    const router = await Dynamsoft.CVR.CaptureVisionRouter.createInstance();
    await router.initSettings("./mrz-template.json");

    const result = await router.capture(blob, "ReadMRZ");
    return result.items
        .filter(item => item.type === Dynamsoft.Core.EnumCapturedResultItemType.CRIT_TEXT_LINE)
        .map(item => item.text);
}
</script>

Two lines come back for a TD3, three for a TD1. Feed them to Dynamsoft Code Parser with the MRTD_TD3_PASSPORT spec and the fields — surname, given names, nationality, dates — arrive parsed and century-resolved, which is the other half of the round trip.

If you would rather not write that, the same ZIP scores directly in the MRZ scanner benchmark: drop it in dataset mode and it reports accuracy per field and per image against the annotations.

Try the Online MRZ Generator

Online MRZ generator: form, generated MRZ string and document preview

Common Issues & Edge Cases

  • Check digit that looks random. The field width is the usual culprit: pad() must run before calculateCheckDigit(), because the filler characters are part of the sum (< counts as 0, so they change the length of the weighted sequence and therefore which weights land where).
  • OCR-B missing from the page. Without the font, the browser falls back to a monospace face whose advance width differs, and 44 characters no longer span the band. Ship the TTF and wait for document.fonts.load() before the first render.
  • A name that came back cut. Fields are fixed width, and this generator truncates at the boundary. ICAO’s rule is stricter — remove whole name components so the field ends on a letter, which signals truncation to the reader — and the demo does not implement it, so a cut name looks complete.
  • Accented characters. Nothing is transliterated. Ü is not in the MRZ character set and is written through as-is, which produces a line no reader will accept. Convert to the ASCII form (MUELLER) before generating.
  • Accuracy numbers from a synthetic set. The images are synthesised, not photographed. The degradations model the capture problems, but not sensor noise, print texture or a hand holding the document. Read the numbers as relative behaviour between settings, not as a production accuracy claim.

FAQ

Can I generate MRZ code without any SDK or license?

Yes. Generation is string arithmetic plus a canvas: the demo runs on plain JavaScript and needs no license key. A license is only required when you read the images back with an SDK.

Are the generated images real identity documents?

No. Every value comes from fixed lists and a seeded random generator, the page carries a SPECIMEN watermark, and the portrait is drawn from primitives rather than photographed. Nothing here is registered anywhere and none of it should be treated as identification.

Can I reproduce a specific country’s document layout?

Not with this generator. It writes the five ICAO 9303 MRZ layouts, not a national template: the field layout is generic, and per-country details such as optional-data conventions are yours to add on top.

Source Code