UNPKG

vision-camera-mrz-scanner

Version:

VisionCamera Frame Processor Plugin to detect and read MRZ data from passports using MLKit Text Recognition.

564 lines (530 loc) 19.4 kB
// logging import and setup import {ListItemData} from '../constants/listItemData'; const countryIsoJson = require('../constants/CountryIsoCodes.json'); /** * It takes a string, and returns a number * @param {string} text - The text to be encoded. * @returns The checkSum function returns the check sum of the input string. */ export const checkSum = (text: string) => { let value = 0; let isNumber = /([0-9])/; for (let i = 0; i < text.length; i++) { if (isNumber.test(text.charAt(i))) { if (i % 3 === 0) { value += 7 * parseInt(text.charAt(i), 10); } else if (i % 3 === 1) { value += 3 * parseInt(text.charAt(i), 10); } else { value += parseInt(text.charAt(i), 10); } } else if (text.charCodeAt(i) === 60) { // if the character is a '<' value += 0; } else { if (i % 3 === 0) { value += 7 * text.charCodeAt(i) - 55; } else if (i % 3 === 1) { value += 3 * text.charCodeAt(i) - 55; } else { value += text.charCodeAt(i) - 55; } } } value %= 10; return value; }; export const parseMRZ = (initialLines: string[]) => { let lines: string[] = []; const firstInitialLastLine = initialLines[initialLines.length - 1]; const secondInitialLastLine = initialLines[initialLines.length - 2]; // if lines.length >= 2, extract and parse two-line MRZ if ( initialLines && initialLines.length >= 2 && firstInitialLastLine && secondInitialLastLine ) { // return undefined if a double left angle bracket character is found in either last line, or second to last line. if ( firstInitialLastLine.indexOf('«') !== -1 || secondInitialLastLine.indexOf('«') !== -1 ) { return undefined; } // remove all empty spaces in each line, capitalize all letters, change all '$' to 'S' initialLines.forEach((line: string) => { while (line.indexOf(' ') !== -1) { line = line.replace(' ', ''); } line = line.toUpperCase(); while (line.indexOf('$') !== -1) { line = line.replace('$', 'S'); } // MLKIT sometimes add a new line character when it finds a new line instead of separating the lines into different elements. while (line.indexOf('\n') !== -1) { lines.push(line.substring(0, line.indexOf('\n'))); line = line.substring(line.indexOf('\n') + 1); } lines.push(line); }); // parse 2 line MRZ if the current line, and the previous line both have 43, 44, or 45 characters for (let i = 1; i < lines.length; i++) { const currentLine = lines[i]; const lastLine = lines[i - 1]; if (currentLine && lastLine) { if ( (currentLine.length > 42 && currentLine.length < 46 && lastLine.length > 42 && lastLine.length < 46) || (currentLine.length > 35 && currentLine.length < 37 && lastLine.length > 35 && lastLine.length < 37) ) { return parse2LineMRZ(lastLine, currentLine); // return parse([lastLine, currentLine]).fields; } } } } // end (lines.length >= 2) if (lines.length >= 3) { // At this point, empty spaces will already be removed and all letters will be capitalized. // return undefined if a double left angle bracket character is found in third to last line. const thirdToLastLine = lines[lines.length - 3]; if (thirdToLastLine && thirdToLastLine.indexOf('«') !== -1) { return undefined; } for (let i = 2; i < lines.length; i++) { const currentLine = lines[i]; const lastLine = lines[i - 1]; const secondToLastLine = lines[i - 2]; if (currentLine && lastLine && secondToLastLine) { if ( currentLine.length > 28 && currentLine.length < 32 && lastLine.length > 28 && lastLine.length < 32 && secondToLastLine.length > 28 && secondToLastLine.length < 32 ) { return parse3LineMRZ(secondToLastLine, lastLine, currentLine); // return parse([secondToLastLine, lastLine, currentLine]); } } } } // end (lines.length >= 3) return undefined; }; /** * It takes two strings, parses them, and returns an object with the parsed data * @param {string} firstRow - string, secondRow: string * @param {string} secondRow - string = * "P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<<<<<\nL898902C<3UTO6908061F9406236ZE184226B<<<<<14" * @returns an object with the following properties: * docMRZ: `\n`, * docType: docType, * issuingCountry: issuingCountry, * givenNames: givenNames.join(' ').trim(), * lastNames: lastNames.join(' ').trim(), * idNumber: idNumber, * nationality */ const parse2LineMRZ = (firstRow: string, secondRow: string) => { let docType = extractDocType(firstRow); let namesFromLine: {givenNames: string[]; lastNames: string[]} = extractNamesFromLine(5, firstRow); let givenNames: string[] = namesFromLine.givenNames; let lastNames: string[] = namesFromLine.lastNames; // Extract idNumber let idNumber = extractIdNumber(secondRow, 0, 9); if (!idNumber) { return undefined; } // extract issuing country from document holder let issuingCountry = extractCountry(firstRow, 2, 5); // extract Nationality of the document holder let nationality = extractCountry(secondRow, 10, 13); // extract dateOfBirth let dob = extractDateOfBirthFromLine(13, secondRow); // Extract gender // let gender = extractGender(secondRow.charAt(20), issuingCountry, docType); let gender = extractGender(secondRow.charAt(20)); // let gender = secondRow.charAt(20); // Extract expiration date then assign the YYYY-MM-DD string format to docExpirationDate let docExpirationDate = extractDateOfExpirationFromLine(21, secondRow); return { docMRZ: `${firstRow}\n${secondRow}`, docType: docType, issuingCountry: issuingCountry, givenNames: givenNames.join(' ').trim(), lastNames: lastNames.join(' ').trim(), idNumber: idNumber, nationality: nationality, dob: dob, gender: gender, docExpirationDate: docExpirationDate, additionalInformation: undefined, // TODO remove? (The logic for extracting additional information was deleted since we're not using it) }; }; /** * It takes in three strings, and returns an object with the following properties: docMRZ, docType, * issuingCountry, givenNames, lastNames, idNumber, nationality, dob, gender, docExpirationDate, and * additionalInformation * @param {string} firstRow - string, * @param {string} secondRow - string, * @param {string} thirdRow - string, * @returns An object with the following properties: * docMRZ: string * docType: string * issuingCountry: string * givenNames: string * lastNames: string * idNumber: string * nationality: string * dob: string * gender: string * docExpirationDate: string * additionalInformation: string */ const parse3LineMRZ = ( firstRow: string, secondRow: string, thirdRow: string, ) => { let docType = extractDocType(firstRow); let namesFromLine: {givenNames: string[]; lastNames: string[]} = extractNamesFromLine(0, thirdRow); let givenNames: string[] = namesFromLine.givenNames; let lastNames: string[] = namesFromLine.lastNames; // Extract idNumber let idNumber = extractIdNumber(firstRow, 5, 14); if (!idNumber) { return undefined; } // Extract issuingCountry let issuingCountry = extractCountry(firstRow, 2, 5); // Extract nationality let nationality = extractCountry(secondRow, 15, 18); // Extract date of birth let dob = extractDateOfBirthFromLine(0, secondRow); // Extract gender let gender = extractGender(secondRow.charAt(7)); // Extract expiration date then store that date as a string in docExpirationDate let docExpirationDate = extractDateOfExpirationFromLine(8, secondRow); // extract additional information from first and second row let additionalInformation = ''; additionalInformation = firstRow.substring(15, 30) + secondRow.substring(18, 29); while (additionalInformation.indexOf('<') !== -1) { additionalInformation = additionalInformation.replace('<', ' '); } additionalInformation = additionalInformation.trim(); return { docMRZ: `${firstRow}\n${secondRow}\n${thirdRow}`, docType: docType, issuingCountry: issuingCountry, givenNames: givenNames.join(' ').trim(), lastNames: lastNames.join(' ').trim(), idNumber: idNumber, nationality: nationality, dob: dob, gender: gender, docExpirationDate: docExpirationDate, additionalInformation: additionalInformation, }; }; /** * It takes a string and returns a string * @param {string} line - string - the line of text that we're parsing * @returns the docType. */ const extractDocType = (line: string) => { const docTypeLen: number = line.charAt(1) === '<' ? 1 : 2; const docType: string = line.substring(0, docTypeLen); return getDocTypeFromCode(docType); }; /** * It takes a document code and returns the document type * @param {string} [docCode] - string * @returns The value of the first item in the array that has a codes array that contains the docCode. */ export const getDocTypeFromCode = (docCode?: string) => { return ListItemData.DOCUMENT_TYPE.find(doc => doc.codes.find(code => code === docCode), )?.value; }; /** * It takes a string, extracts a substring from it, replaces all 'O' with '0', calculates the checksum * of the substring, compares it with the checksum on the document, removes all '<' from the substring, * and returns the substring. * @param {string} line - the line of text that contains the ID number * @param {number} startingIndex - the index of the first character of the ID number * @param {number} endingIndex - the index of the last character of the id number * @returns The ID number. */ const extractIdNumber = ( line: string, startingIndex: number, endingIndex: number, ) => { let idNumber = line.substring(startingIndex, endingIndex); // replace all 'O' with '0' while (idNumber.indexOf('O') !== -1) { idNumber = idNumber.replace('O', '0'); } // calculate the checksum using idNumber then compare it with the checksum on the document let idNumberCheckSum = checkSum(idNumber); if (idNumberCheckSum === parseInt(line.charAt(endingIndex), 10)) { } // remove all '<' from idNumber while (idNumber.indexOf('<') !== -1) { idNumber = idNumber.replace('<', ''); } return idNumber; }; /** * A CountryIsoCode is an object with a string property called isoCountryNumber, a string property * called isoCountryCode, and a string property called countryName. * @property {string} isoCountryNumber - The ISO country number. * @property {string} isoCountryCode - The ISO 3166-1 alpha-2 code for the country. * @property {string} countryName - The name of the country. */ type CountryIsoCode = { isoCountryNumber: string; isoCountryCode: string; countryName: string; }; /** * It takes a string, and two numbers, and returns a string. * @param {string} line - string - the line of text that we're parsing * @param {number} startingIndex - the index of the first character of the country code * @param {number} endingIndex - number = line.length, * @returns A function that takes 3 parameters and returns a string. */ const extractCountry = ( line: string, startingIndex: number, endingIndex: number, ) => { let country = line.substring(startingIndex, endingIndex); // ensure 6's, 0's, and 2's are swapped out with G's, O's, and Z'z respectively country = replaceNumbersWithCorrespondingLetters(country); // if country is germany, return DEU if (country === 'D<<') { return 'DEU'; } // if country is in countryIsoJson, return it, if not return undefined return countryIsoJson.find( (item: CountryIsoCode) => item.isoCountryCode === country, )?.isoCountryCode; }; /** * It takes a starting index and a line of text, and returns a date of expiration if it can find one. * @param {number} startingIndex - number, * @param {string} line - string - the line of text that contains the date of expiration * @returns A date string in the format of YYYY-MM-DD */ const extractDateOfExpirationFromLine = ( startingIndex: number, line: string, ) => { // replace all 'O' with '0' while (line.substring(startingIndex, startingIndex + 6).indexOf('O') !== -1) { line = line.replace('O', '0'); } let twoDigitYearOfExpiration = line.substring( startingIndex, startingIndex + 2, ); let twoDigitMonthOfExpiration = line.substring( startingIndex + 2, startingIndex + 4, ); let twoDigitDayOfExpiration = line.substring( startingIndex + 4, startingIndex + 6, ); let fullYearOfExpDate = 2000 + parseInt(twoDigitYearOfExpiration, 10); let currentYear = new Date().getFullYear(); if (fullYearOfExpDate - currentYear > 10) { fullYearOfExpDate -= 100; } // ensure expiration date is valid. if not, return undefined let docExpirationDate = `${fullYearOfExpDate}-${twoDigitMonthOfExpiration}-${twoDigitDayOfExpiration}`; if (new Date(docExpirationDate).toString() === 'Invalid Date') { return undefined; } // Confirm checkSum for date of expiration let expDateCheckSum = checkSum( twoDigitYearOfExpiration + twoDigitMonthOfExpiration + twoDigitDayOfExpiration, ); if (expDateCheckSum === parseInt(line.charAt(startingIndex + 6), 10)) { return docExpirationDate; } return undefined; }; /** * It takes a starting index and a line of text, and returns a date of birth if the checksum is valid, * otherwise it returns undefined * @param {number} startingIndex - The index of the first character of the date of birth in the line * @param {string} line - the line of text that contains the date of birth * @returns A function that takes two parameters, startingIndex and line. */ const extractDateOfBirthFromLine = (startingIndex: number, line: string) => { // replace all 'O' with '0' while (line.substring(startingIndex, startingIndex + 6).indexOf('O') !== -1) { line = line.replace('O', '0'); } let twoDigitYearOfBirth = line.substring(startingIndex, startingIndex + 2); let twoDigitMonthOfBirth = line.substring( startingIndex + 2, startingIndex + 4, ); let twoDigitDayOfBirth = line.substring(startingIndex + 4, startingIndex + 6); let fullYearOfBirth = 2000 + parseInt(twoDigitYearOfBirth, 10); let currentYear = new Date().getFullYear(); if (currentYear - fullYearOfBirth < 0) { fullYearOfBirth -= 100; } // Confirm checkSum for date of birth let dobCheckSum = checkSum( twoDigitYearOfBirth + twoDigitMonthOfBirth + twoDigitDayOfBirth, ); let dob = `${fullYearOfBirth}-${twoDigitMonthOfBirth}-${twoDigitDayOfBirth}`; // ensure date of birth is a valid date. if not, return undefined if (new Date(dob).toString() === 'Invalid Date') { return undefined; } if (dobCheckSum === parseInt(line.charAt(startingIndex + 6), 10)) { return dob; } return undefined; }; /** * It takes a string and extracts the given names and last names from it. * @param {number} startingIndex - number - the index of the line where the names start * @param {string} line - the line of text that contains the names * @returns An object with two properties: givenNames and lastNames. */ const extractNamesFromLine = (startingIndex: number, line: string) => { let angleBracketCount = 0; let lastNamesExtracted = false; let lastNames = []; let lastName = ''; let givenNames = []; let givenName = ''; for (let i = startingIndex; i < line.length; i++) { if (line.charAt(i) !== '<' && !lastNamesExtracted) { angleBracketCount = 0; lastName += line.charAt(i); if (i === line.length - 1) { lastNames.push(lastName); } } // append to givenName else if (line.charAt(i) !== '<' && lastNamesExtracted) { angleBracketCount = 0; givenName += line.charAt(i); if (i === line.length - 1) { givenNames.push(givenName); } } // append to lastNames[] else if ( line.charAt(i) === '<' && angleBracketCount === 0 && !lastNamesExtracted ) { lastNames.push(lastName); lastName = ''; angleBracketCount++; } // append to givenNames[] else if ( line.charAt(i) === '<' && angleBracketCount === 0 && lastNamesExtracted ) { givenNames.push(givenName); givenName = ''; angleBracketCount++; } // switch from lastName extraction to givenName extraction else if ( line.charAt(i) === '<' && angleBracketCount === 1 && !lastNamesExtracted ) { lastNames.push(lastName); lastNamesExtracted = true; angleBracketCount = 0; } // end extraction else if ( line.charAt(i) === '<' && angleBracketCount === 1 && lastNamesExtracted ) { givenNames.push(givenName); break; } } // remove empty strings from givenNames and lastNames givenNames = givenNames.filter(fName => fName.length > 0); lastNames = lastNames.filter(lName => lName.length > 0); // ensure 6's, 0's, and 2's are swapped out with G's, O's, and Z'z respectively for (let i = 0; i < givenNames.length; i++) { const initialGivenName = givenNames[i]; if (initialGivenName) { givenNames[i] = replaceNumbersWithCorrespondingLetters(initialGivenName); } } for (let i = 0; i < lastNames.length; i++) { const initialLastName = lastNames[i]; if (initialLastName) { lastNames[i] = replaceNumbersWithCorrespondingLetters(initialLastName); } } return { givenNames, lastNames, }; }; /** * It replaces all the numbers in a string with their corresponding letters * @param {string} word - string - the word that you want to replace the numbers with letters * @returns The word with the numbers replaced with the corresponding letters. */ const replaceNumbersWithCorrespondingLetters = (word: string) => { while (word.indexOf('0') !== -1) { word = word.replace('0', 'O'); } while (word.indexOf('6') !== -1) { word = word.replace('6', 'G'); } while (word.indexOf('2') !== -1) { word = word.replace('2', 'Z'); } while (word.indexOf('1') !== -1) { word = word.replace('1', 'I'); } return word; }; /** * If the letter is '<', return 'U', if the letter is 'H', return 'M', otherwise return the letter * @param {string} letter - string - the letter to extract the gender from * @returns the letter if it is not '<' or 'H'. */ const extractGender = (letter: string) => { if (letter === '<') { return 'U'; } if (letter === 'H') { return 'M'; } return letter; };