August 3, 2026
Reading a Passport Like a Standard, Not a Screenshot
The problem
A document-scanning feature I worked on needed to turn a photographed or scanned passport into structured fields: name, passport number, date of birth, expiry date, nationality. The OCR step (AWS Textract, though the approach is provider-agnostic) hands back a flat list of text lines with no structure at all. Passports also have wildly inconsistent front-page layouts across countries, fonts, and print quality, so pattern-matching labels like "Given Name" or "Date of Birth" directly on the visible text is brittle. OCR misreads a character, a label wraps to two lines, and the whole field comes back null.
The approach
Every passport that follows ICAO Doc 9303 (which is effectively all of them) has a Machine Readable Zone: two 44-character lines at the bottom of the photo page, printed in a fixed-width OCR-friendly font. Unlike the rest of the page, the MRZ's format is not just consistent. It's specified down to the character offset:
// Simplified illustration of the TD3 MRZ layout
const line2 = raw.padEnd(44, '<');
const passportNumber = line2.substring(0, 9).replace(/</g, '');
const nationality = line2.substring(10, 13);
const dobRaw = line2.substring(13, 19); // YYMMDD
const genderRaw = line2.substring(20, 21);
const expiryRaw = line2.substring(21, 27); // YYMMDDThat turns parsing from "find text near a label" into "read fixed offsets from a string", a much smaller surface for OCR noise to break.
The one genuine ambiguity in the format is the date of birth: MRZ stores years as two digits, so 05 is either 1905 or 2005 and the format gives no extra bit to disambiguate. The fix was a pivot-year rule: compare the two-digit year against the current year's last two digits. If it's greater, assume the 1900s, otherwise the 2000s. It's a heuristic, but a safe one, since nobody scanning a passport for a live workflow has a birth year more than a lifetime in the future.
I still kept a fallback path for the rest of the document (address, parent's name, file number on the reverse side) using label-based regex matching over the OCR lines, because those fields aren't standardized the way the MRZ is. But the MRZ became the primary, trusted source, and the regex fallback only fills gaps the standard doesn't cover.
What I learned
The instinct when OCR output is messy is to write more defensive parsing: more regexes, more edge cases. The actual fix was upstream of the code: find out if the domain already has a published standard, and if it does, parse that instead of the free-form text around it. A spec gives you guarantees regex never will.
What's next
The next gap is validating parsed MRZ fields against a checksum. TD3 MRZ lines include check digits for the passport number, DOB, and expiry that I haven't wired up yet. That would catch OCR misreads (e.g. a 0 read as O) before they ever reach downstream data, rather than relying on "does this look like a valid date" as the only sanity check.