├── Makefile ├── LICENSE ├── README.md ├── PhoneNumberNormalizer.js ├── fuzz.js ├── xml2meta.py ├── test.js └── PhoneNumber.js /Makefile: -------------------------------------------------------------------------------- 1 | all: PhoneNumberMetadata.js 2 | 3 | %.js: %.xml xml2meta.py 4 | python xml2meta.py $< > $@ 5 | 6 | PhoneNumberMetadata.xml: 7 | curl https://raw.githubusercontent.com/googlei18n/libphonenumber/master/resources/PhoneNumberMetadata.xml > $@ 8 | 9 | clean: 10 | rm -f PhoneNumberMetadata.xml *~ 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2012, Mozilla Foundation 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PhoneNumber.js 2 | 3 | 4 | PhoneNumber.js is a JavaScript library to verify and format phone numbers. 5 | It is similar in purpose to Google's libphonenumber library, with the main difference 6 | that Google's code is some incredibly ugly spaghetti code that was cross-compiled 7 | from Java and uses around 10MB of memory. 8 | 9 | The memory use of PhoneNumber.js starts around 60k and increases as region meta data 10 | is unpacked. Depending on the memory layout of the specific JavaScript engine, peak 11 | memory use should be below 200k. If you mostly format numbers from one or a few 12 | regions, memory use should be pretty close to 60k. 13 | 14 | PhoneNumber.js uses libphonenumber's PhoneNumberMetadata.xml database of known 15 | phone number formats. Use "make" to download the xml file and translate it 16 | into PhoneNumber.js's internal format. 17 | 18 | # Copyright and license 19 | 20 | PhoneNumber.js was written by Andreas Gal as part of Mozilla's 21 | Firefox OS (Boot to Gecko) project and is licensed under the Apache License, Version 2.0. 22 | 23 | 24 | -------------------------------------------------------------------------------- /PhoneNumberNormalizer.js: -------------------------------------------------------------------------------- 1 | /* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ 2 | /* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ 3 | 4 | var PhoneNumberNormalizer = (function() { 5 | const UNICODE_DIGITS = /[\uFF10-\uFF19\u0660-\u0669\u06F0-\u06F9]/g; 6 | const VALID_ALPHA_PATTERN = /[a-zA-Z]/g; 7 | const LEADING_PLUS_CHARS_PATTERN = /^[+\uFF0B]+/g; 8 | const NON_DIALABLE_CHARS = /[^,#+\*\d]/g; 9 | 10 | // Map letters to numbers according to the ITU E.161 standard 11 | var E161 = { 12 | 'a': 2, 'b': 2, 'c': 2, 13 | 'd': 3, 'e': 3, 'f': 3, 14 | 'g': 4, 'h': 4, 'i': 4, 15 | 'j': 5, 'k': 5, 'l': 5, 16 | 'm': 6, 'n': 6, 'o': 6, 17 | 'p': 7, 'q': 7, 'r': 7, 's': 7, 18 | 't': 8, 'u': 8, 'v': 8, 19 | 'w': 9, 'x': 9, 'y': 9, 'z': 9 20 | }; 21 | 22 | // Normalize a number by converting unicode numbers and symbols to their 23 | // ASCII equivalents and removing all non-dialable characters. 24 | function NormalizeNumber(number, numbersOnly) { 25 | if (typeof number !== 'string') { 26 | return ''; 27 | } 28 | 29 | number = number.replace(UNICODE_DIGITS, 30 | function (ch) { 31 | return String.fromCharCode(48 + (ch.charCodeAt(0) & 0xf)); 32 | }); 33 | if (!numbersOnly) { 34 | number = number.replace(VALID_ALPHA_PATTERN, 35 | function (ch) { 36 | return String(E161[ch.toLowerCase()] || 0); 37 | }); 38 | } 39 | number = number.replace(LEADING_PLUS_CHARS_PATTERN, "+"); 40 | number = number.replace(NON_DIALABLE_CHARS, ""); 41 | return number; 42 | }; 43 | 44 | 45 | return { 46 | Normalize: NormalizeNumber 47 | }; 48 | })(); 49 | -------------------------------------------------------------------------------- /fuzz.js: -------------------------------------------------------------------------------- 1 | /* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ 2 | /* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ 3 | 4 | load("PhoneNumberMetadata.js"); 5 | load("PhoneNumberNormalizer.js"); 6 | load("PhoneNumber.js"); 7 | 8 | let gCountryList; 9 | 10 | function TestProperties(dial, currentRegion) { 11 | print("test: " + dial + ", " + currentRegion); 12 | var result = PhoneNumber.Parse(dial, currentRegion); 13 | if (result) { 14 | var tmp = result.internationalFormat; 15 | tmp = result.internationalNumber; 16 | tmp = result.nationalNumber; 17 | tmp = result.nationalFormat; 18 | } else { 19 | } 20 | } 21 | 22 | function makeid(maxSize) 23 | { 24 | var text = ""; 25 | var possible = "0123456789"; 26 | var length = Math.floor(Math.random() * maxSize); 27 | for (var i=0; i < length; i++ ) 28 | text += (possible.charAt(Math.floor(Math.random() * possible.length))); 29 | 30 | return text; 31 | } 32 | 33 | function getRandomCC() { 34 | if (gCountryList) { 35 | return gCountryList[Math.floor(Math.random() * gCountryList.length)]; 36 | } 37 | 38 | gCountryList = []; 39 | for (let id in PHONE_NUMBER_META_DATA) { 40 | let obj = PHONE_NUMBER_META_DATA[id]; 41 | if (Array.isArray(obj)) { 42 | for (let i = 0; i < obj.length; i++) { 43 | let cc = obj[i].substring(2,4); 44 | if (cc != "00") 45 | gCountryList.push(cc); 46 | } 47 | } else { 48 | let cc = obj.substring(2,4); 49 | if (cc != "00") 50 | gCountryList.push(cc); 51 | } 52 | } 53 | return gCountryList[Math.floor(Math.random() * gCountryList.length)]; 54 | } 55 | 56 | for (var i = 0; i < 100000; i++) { 57 | let dial = makeid(15); 58 | if (i % 3 == 0) { 59 | let x = Math.floor(Math.random() * 3); 60 | switch (x) { 61 | case 0: 62 | dial = ("+" + dial); 63 | break; 64 | case 1: 65 | dial = ("+0" + dial); 66 | break; 67 | case 2: 68 | dial = ("0" + dial); 69 | break; 70 | } 71 | } 72 | 73 | TestProperties(dial, getRandomCC()); 74 | } 75 | -------------------------------------------------------------------------------- /xml2meta.py: -------------------------------------------------------------------------------- 1 | from array import array 2 | from optparse import OptionParser 3 | from xml.dom.minidom import parseString 4 | import re 5 | import sys 6 | 7 | # parse command line arguments 8 | use = "Usage: %prog [options] PhoneNumberMetadata.xml" 9 | parser = OptionParser(usage = use) 10 | parser.add_option("-t", "--tests", dest="tests", action="store_true", default=False, help="Emit tests instead of meta data.") 11 | options, args = parser.parse_args() 12 | 13 | # we expect the dictionary name to be present 14 | if len(args) < 1: 15 | print("Missing input file name.") 16 | exit(-1) 17 | 18 | # read the input dictionary file 19 | file = open(args[0]) 20 | data = file.read() 21 | file.close() 22 | 23 | def quote(x): 24 | return "\"" + x.replace("\\", "\\\\") + "\"" 25 | 26 | def nodeValue(x): 27 | if x == None: 28 | return "" 29 | return quote(x.nodeValue) 30 | 31 | def text(nodelist): 32 | rc = [] 33 | for node in nodelist: 34 | if node.nodeType == node.TEXT_NODE: 35 | rc.append(node.data) 36 | return quote("".join(rc)) 37 | 38 | def strip(x): 39 | return re.sub(r"\s", "", x) 40 | 41 | def pattern(x, type): 42 | return strip(text(x[0].getElementsByTagName(type)[0].childNodes)) 43 | 44 | def format(x): 45 | if len(x) == 0: 46 | return "" 47 | assert len(x) == 1 48 | result = [] 49 | for numberFormat in x[0].getElementsByTagName("numberFormat"): 50 | attr = numberFormat.attributes 51 | pattern = nodeValue(attr.get("pattern")) 52 | nationalPrefixFormattingRule = nodeValue(attr.get("nationalPrefixFormattingRule")) 53 | format = text(numberFormat.getElementsByTagName("format")[0].childNodes) 54 | leadingDigits = numberFormat.getElementsByTagName("leadingDigits"); 55 | if len(leadingDigits) > 0: 56 | leadingDigits = strip(text(leadingDigits[0].childNodes)) 57 | else: 58 | leadingDigits = "" 59 | intlFormat = numberFormat.getElementsByTagName("intlFormat") 60 | if len(intlFormat) == 1: 61 | intlFormat = text(intlFormat[0].childNodes) 62 | else: 63 | assert len(intlFormat) == 0 64 | intlFormat = ""; 65 | 66 | result.append("[" + ",".join([pattern, format, leadingDigits, nationalPrefixFormattingRule, intlFormat]) + "]") 67 | return "[" + ",".join(result) + "]" 68 | 69 | # go through the phone number meta data and convert and filter it into a JS file we will include 70 | dom = parseString(data) 71 | territories = dom.getElementsByTagName("phoneNumberMetadata")[0].getElementsByTagName("territories")[0].getElementsByTagName("territory") 72 | map = {} 73 | examples = [] 74 | for territory in territories: 75 | attr = territory.attributes 76 | region = nodeValue(attr.get("id")) 77 | countryCode = nodeValue(attr.get("countryCode")) 78 | internationalPrefix = nodeValue(attr.get("internationalPrefix")) 79 | nationalPrefix = nodeValue(attr.get("nationalPrefix")) 80 | nationalPrefixForParsing = strip(nodeValue(attr.get("nationalPrefixForParsing"))) 81 | nationalPrefixTransformRule = nodeValue(attr.get("nationalPrefixTransformRule")) 82 | if region == '"BR"': 83 | nationalPrefixForParsing = '"(?:0|90)(?:(1[245]|2[135]|[34]1)(\\\\d{10,11}))?"' 84 | nationalPrefixFormattingRule = nodeValue(attr.get("nationalPrefixFormattingRule")) 85 | possiblePattern = pattern(territory.getElementsByTagName("generalDesc"), "possibleNumberPattern") 86 | nationalPattern = pattern(territory.getElementsByTagName("generalDesc"), "nationalNumberPattern") 87 | formats = format(territory.getElementsByTagName("availableFormats")) 88 | mainCountryForCode = nodeValue(attr.get("mainCountryForCode")); 89 | if not countryCode in map: 90 | map[countryCode] = [] 91 | map[countryCode].append("'[{0},{1},{2},{3},{4},{5},{6},{7},{8}]'".format(region, 92 | internationalPrefix, 93 | nationalPrefix, 94 | nationalPrefixForParsing, 95 | nationalPrefixTransformRule, 96 | nationalPrefixFormattingRule, 97 | possiblePattern, 98 | nationalPattern, 99 | formats)) 100 | if len(map[countryCode]) > 1 and mainCountryForCode == "\"true\"": 101 | x = map[countryCode] 102 | t = x[0] 103 | x[0] = x[len(x)-1] 104 | x[len(x)-1] = t 105 | if region != "\"001\"": 106 | for example in territory.getElementsByTagName("exampleNumber"): 107 | examples.append("Parse({0}, {1});".format(text(example.childNodes), region)) 108 | 109 | print("/* Automatically generated. Do not edit. */") 110 | output = [] 111 | 112 | if options.tests: 113 | for example in examples: 114 | print(example) 115 | sys.exit() 116 | 117 | print("const PHONE_NUMBER_META_DATA = {"); 118 | for cc in map: 119 | entry = map[cc] 120 | if len(entry) > 1: 121 | output.append(cc + ": [" + ",".join(entry) + "]") 122 | else: 123 | output.append(cc + ": " + entry[0]) 124 | for line in output: 125 | print(line + ",") 126 | print("};") 127 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | /* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ 2 | /* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ 3 | 4 | load("PhoneNumberMetadata.js"); 5 | load("PhoneNumberNormalizer.js") 6 | load("PhoneNumber.js"); 7 | 8 | function IsPlain(dial, expected) { 9 | var result = PhoneNumber.IsPlain(dial); 10 | if (result != expected) { 11 | print(dial + ", expected: " + expected); 12 | print("got: " + result); 13 | } 14 | } 15 | 16 | function Normalize(dial, expected) { 17 | var result = PhoneNumberNormalizer.Normalize(dial); 18 | if (result != expected) { 19 | print("expected: " + expected); 20 | print("got: " + result); 21 | } 22 | } 23 | 24 | function CantParse(dial, currentRegion) { 25 | var result = PhoneNumber.Parse(dial, currentRegion); 26 | if (result) { 27 | print("expected: does not parse"); 28 | print("got: " + dial + " " + currentRegion); 29 | } 30 | } 31 | 32 | function Parse(dial, currentRegion) { 33 | var result = PhoneNumber.Parse(dial, currentRegion); 34 | if (!result) { 35 | print("expected: parses"); 36 | print("got: " + dial + " " + currentRegion); 37 | } 38 | return result; 39 | } 40 | 41 | function Test(dial, currentRegion, nationalNumber, region) { 42 | var result = Parse(dial, currentRegion); 43 | if (result.region != region || result.nationalNumber != nationalNumber) { 44 | print("expected: " + nationalNumber + " " + region); 45 | print("got: " + result.nationalNumber + " " + result.region); 46 | } 47 | return result; 48 | } 49 | 50 | function TestProperties(dial, currentRegion) { 51 | var result = PhoneNumber.Parse(dial, currentRegion); 52 | if (result) { 53 | var tmp = result.internationalFormat; 54 | tmp = result.internationalNumber; 55 | tmp = result.nationalNumber; 56 | tmp = result.nationalFormat; 57 | } 58 | } 59 | 60 | function Format(dial, currentRegion, nationalNumber, region, nationalFormat, internationalFormat) { 61 | var result = Test(dial, currentRegion, nationalNumber, region); 62 | if (result.nationalFormat != nationalFormat || 63 | result.internationalFormat != internationalFormat) { 64 | print("expected: " + nationalFormat + " " + internationalFormat); 65 | print("got: " + result.nationalFormat + " " + result.internationalFormat); 66 | return result; 67 | } 68 | } 69 | 70 | function IsEqual(lhs, rhs, currentRegion) { 71 | if (lhs !== rhs) { 72 | print("expected: " + rhs); 73 | print("got: " + lhs); 74 | return false; 75 | } 76 | return true; 77 | } 78 | 79 | function AllEqual(list, currentRegion) { 80 | for (var n = 0; n < list.length; ++n) { 81 | var parsed = Parse(list[n], currentRegion); 82 | if (!parsed) { 83 | print("can't parse: " + list[n]); 84 | return; 85 | } 86 | list[n] = parsed.nationalFormat; 87 | } 88 | for (var n = 1; n < list.length; ++n) { 89 | if (list[0] != list[n]) { 90 | print("mismatch: " + list[0] + " and " + list[n]); 91 | } 92 | } 93 | } 94 | 95 | // Test whether could a string be a phone number. 96 | IsPlain(null, false); 97 | IsPlain("", false); 98 | IsPlain("1", true); 99 | IsPlain("*2", true); // Real number used in Venezuela 100 | IsPlain("*8", true); // Real number used in Venezuela 101 | IsPlain("12", true); 102 | IsPlain("123", true); 103 | IsPlain("1a2", false); 104 | IsPlain("12a", false); 105 | IsPlain("1234", true); 106 | IsPlain("123a", false); 107 | IsPlain("+", true); 108 | IsPlain("+1", true); 109 | IsPlain("+12", true); 110 | IsPlain("+123", true); 111 | IsPlain("()123", false); 112 | IsPlain("(1)23", false); 113 | IsPlain("(12)3", false); 114 | IsPlain("(123)", false); 115 | IsPlain("(123)4", false); 116 | IsPlain("(123)4", false); 117 | IsPlain("123;ext=", false); 118 | IsPlain("123;ext=1", false); 119 | IsPlain("123;ext=1234567", false); 120 | IsPlain("123;ext=12345678", false); 121 | IsPlain("123 ext:1", false); 122 | IsPlain("123 ext:1#", false); 123 | IsPlain("123-1#", false); 124 | IsPlain("123 1#", false); 125 | IsPlain("123 12345#", false); 126 | IsPlain("123 +123456#", false); 127 | 128 | // Getting international number back from intl number. 129 | TestProperties("+13442074"); 130 | 131 | // Test parsing national numbers. 132 | Parse("033316005", "NZ"); 133 | Parse("03-331 6005", "NZ"); 134 | Parse("03 331 6005", "NZ"); 135 | // Testing international prefixes. 136 | // Should strip country code. 137 | Parse("0064 3 331 6005", "NZ"); 138 | 139 | // Test CA before US because CA has to import meta-information for US. 140 | Parse("4031234567", "CA"); 141 | Parse("(416) 585-4319", "CA"); 142 | Parse("647-967-4357", "CA"); 143 | Parse("416-716-8768", "CA"); 144 | Parse("18002684646", "CA"); 145 | Parse("416-445-9119", "CA"); 146 | Parse("1-800-668-6866", "CA"); 147 | Parse("(416) 453-6486", "CA"); 148 | Parse("(647) 268-4778", "CA"); 149 | Parse("647-218-1313", "CA"); 150 | Parse("+1 647-209-4642", "CA"); 151 | Parse("416-559-0133", "CA"); 152 | Parse("+1 647-639-4118", "CA"); 153 | Parse("+12898803664", "CA"); 154 | Parse("780-901-4687", "CA"); 155 | Parse("+14167070550", "CA"); 156 | Parse("+1-647-522-6487", "CA"); 157 | Parse("(416) 877-0880", "CA"); 158 | 159 | // Try again, but this time we have an international number with region rode US. It should 160 | // recognize the country code and parse accordingly. 161 | Parse("01164 3 331 6005", "US"); 162 | Parse("+64 3 331 6005", "US"); 163 | Parse("64(0)64123456", "NZ"); 164 | // Check that using a "/" is fine in a phone number. 165 | Parse("123/45678", "DE"); 166 | Parse("123-456-7890", "US"); 167 | 168 | // Test parsing international numbers. 169 | Parse("+1 (650) 333-6000", "NZ"); 170 | Parse("1-650-333-6000", "US"); 171 | // Calling the US number from Singapore by using different service providers 172 | // 1st test: calling using SingTel IDD service (IDD is 001) 173 | Parse("0011-650-333-6000", "SG"); 174 | // 2nd test: calling using StarHub IDD service (IDD is 008) 175 | Parse("0081-650-333-6000", "SG"); 176 | // 3rd test: calling using SingTel V019 service (IDD is 019) 177 | Parse("0191-650-333-6000", "SG"); 178 | // Calling the US number from Poland 179 | Parse("0~01-650-333-6000", "PL"); 180 | // Using "++" at the start. 181 | Parse("++1 (650) 333-6000", "PL"); 182 | // Using a full-width plus sign. 183 | Parse("\uFF0B1 (650) 333-6000", "SG"); 184 | // The whole number, including punctuation, is here represented in full-width form. 185 | Parse("\uFF0B\uFF11\u3000\uFF08\uFF16\uFF15\uFF10\uFF09" + 186 | "\u3000\uFF13\uFF13\uFF13\uFF0D\uFF16\uFF10\uFF10\uFF10", 187 | "SG"); 188 | 189 | // Test parsing with leading zeros. 190 | Parse("+39 02-36618 300", "NZ"); 191 | Parse("02-36618 300", "IT"); 192 | Parse("312 345 678", "IT"); 193 | 194 | // Test parsing numbers in Argentina. 195 | Parse("+54 9 343 555 1212", "AR"); 196 | Parse("0343 15 555 1212", "AR"); 197 | Parse("+54 9 3715 65 4320", "AR"); 198 | Parse("03715 15 65 4320", "AR"); 199 | Parse("+54 11 3797 0000", "AR"); 200 | Parse("011 3797 0000", "AR"); 201 | Parse("+54 3715 65 4321", "AR"); 202 | Parse("03715 65 4321", "AR"); 203 | Parse("+54 23 1234 0000", "AR"); 204 | Parse("023 1234 0000", "AR"); 205 | 206 | // Test numbers in Mexico 207 | Parse("+52 (449)978-0001", "MX"); 208 | Parse("01 (449)978-0001", "MX"); 209 | Parse("(449)978-0001", "MX"); 210 | Parse("+52 1 33 1234-5678", "MX"); 211 | Parse("044 (33) 1234-5678", "MX"); 212 | Parse("045 33 1234-5678", "MX"); 213 | 214 | // Test that lots of spaces are ok. 215 | Parse("0 3 3 3 1 6 0 0 5", "NZ"); 216 | 217 | // Test omitting the current region. This is only valid when the number starts 218 | // with a '+'. 219 | Parse("+64 3 331 6005"); 220 | Parse("+64 3 331 6005", null); 221 | 222 | // US numbers 223 | Format("19497261234", "US", "9497261234", "US", "(949) 726-1234", "+1 949-726-1234"); 224 | 225 | // Try a couple german numbers from the US with various access codes. 226 | Format("49451491934", "US", "451491934", "DE", "0451 491934", "+49 451 491934"); 227 | Format("+49451491934", "US", "451491934", "DE", "0451 491934", "+49 451 491934"); 228 | Format("01149451491934", "US", "451491934", "DE", "0451 491934", "+49 451 491934"); 229 | 230 | // Now try dialing the same number from within the German region. 231 | Format("451491934", "DE", "451491934", "DE", "0451 491934", "+49 451 491934"); 232 | Format("0451491934", "DE", "451491934", "DE", "0451 491934", "+49 451 491934"); 233 | 234 | // Numbers in italy keep the leading 0 in the city code when dialing internationally. 235 | Format("0577-555-555", "IT", "0577555555", "IT", "05 7755 5555", "+39 05 7755 5555"); 236 | 237 | // Colombian international number without the leading "+" 238 | Format("5712234567", "CO", "12234567", "CO", "(1) 2234567", "+57 1 2234567"); 239 | 240 | // Telefonica tests 241 | Format("612123123", "ES", "612123123", "ES", "612 12 31 23", "+34 612 12 31 23"); 242 | 243 | // Chile mobile number from a landline 244 | Format("0997654321", "CL", "997654321", "CL", "(99) 765 4321", "+56 99 765 4321"); 245 | 246 | // Chile mobile number from another mobile number 247 | Format("997654321", "CL", "997654321", "CL", "(99) 765 4321", "+56 99 765 4321"); 248 | 249 | // Dialing 911 in the US. This is not a national number. 250 | CantParse("911", "US"); 251 | 252 | // China mobile number with a 0 in it 253 | Format("15955042864", "CN", "15955042864", "CN", "0159 5504 2864", "+86 159 5504 2864"); 254 | 255 | // Testing international region numbers. 256 | CantParse("883510000000091", "001"); 257 | Format("+883510000000092", "001", "510000000092", "001", "510 000 000 092", "+883 510 000 000 092"); 258 | Format("883510000000093", "FR", "510000000093", "001", "510 000 000 093", "+883 510 000 000 093"); 259 | Format("+883510000000094", "FR", "510000000094", "001", "510 000 000 094", "+883 510 000 000 094"); 260 | Format("883510000000095", "US", "510000000095", "001", "510 000 000 095", "+883 510 000 000 095"); 261 | Format("+883510000000096", "US", "510000000096", "001", "510 000 000 096", "+883 510 000 000 096"); 262 | CantParse("979510000012", "001"); 263 | Format("+979510000012", "001", "510000012", "001", "5 1000 0012", "+979 5 1000 0012"); 264 | 265 | // Test normalizing numbers. Only 0-9,#* are valid in a phone number. 266 | Normalize("+ABC # * , 9 _ 1 _0", "+222#*,910"); 267 | Normalize("ABCDEFGHIJKLMNOPQRSTUVWXYZ", "22233344455566677778889999"); 268 | Normalize("abcdefghijklmnopqrstuvwxyz", "22233344455566677778889999"); 269 | 270 | // 8 and 9 digit numbers with area code in Brazil with collect call prefix (90) 271 | AllEqual(["01187654321","0411187654321","551187654321","90411187654321","+551187654321"],"BR"); 272 | AllEqual(["011987654321","04111987654321","5511987654321","904111987654321","+5511987654321"],"BR") 273 | 274 | IsEqual(PhoneNumberNormalizer.Normalize("123abc", true), "123"); 275 | IsEqual(PhoneNumberNormalizer.Normalize("12345", true), "12345"); 276 | IsEqual(PhoneNumberNormalizer.Normalize("1abcd", false), "12223"); 277 | -------------------------------------------------------------------------------- /PhoneNumber.js: -------------------------------------------------------------------------------- 1 | /* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ 2 | /* vim: set shiftwidth=2 tabstop=2 autoindent cindent expandtab: */ 3 | 4 | var PhoneNumber = (function (dataBase) { 5 | // Use strict in our context only - users might not want it 6 | 'use strict'; 7 | 8 | const MAX_PHONE_NUMBER_LENGTH = 50; 9 | const NON_ALPHA_CHARS = /[^a-zA-Z]/g; 10 | const NON_DIALABLE_CHARS = /[^,#+\*\d]/g; 11 | const NON_DIALABLE_CHARS_ONCE = new RegExp(NON_DIALABLE_CHARS.source); 12 | const BACKSLASH = /\\/g; 13 | const SPLIT_FIRST_GROUP = /^(\d+)(.*)$/; 14 | const LEADING_PLUS_CHARS_PATTERN = /^[+\uFF0B]+/g; 15 | 16 | // Format of the string encoded meta data. If the name contains "^" or "$" 17 | // we will generate a regular expression from the value, with those special 18 | // characters as prefix/suffix. 19 | const META_DATA_ENCODING = ["region", 20 | "^(?:internationalPrefix)", 21 | "nationalPrefix", 22 | "^(?:nationalPrefixForParsing)", 23 | "nationalPrefixTransformRule", 24 | "nationalPrefixFormattingRule", 25 | "^possiblePattern$", 26 | "^nationalPattern$", 27 | "formats"]; 28 | 29 | const FORMAT_ENCODING = ["^pattern$", 30 | "nationalFormat", 31 | "^leadingDigits", 32 | "nationalPrefixFormattingRule", 33 | "internationalFormat"]; 34 | 35 | var regionCache = Object.create(null); 36 | 37 | // Parse an array of strings into a convenient object. We store meta 38 | // data as arrays since thats much more compact than JSON. 39 | function ParseArray(array, encoding, obj) { 40 | for (var n = 0; n < encoding.length; ++n) { 41 | var value = array[n]; 42 | if (!value) 43 | continue; 44 | var field = encoding[n]; 45 | var fieldAlpha = field.replace(NON_ALPHA_CHARS, ""); 46 | if (field != fieldAlpha) 47 | value = new RegExp(field.replace(fieldAlpha, value)); 48 | obj[fieldAlpha] = value; 49 | } 50 | return obj; 51 | } 52 | 53 | // Parse string encoded meta data into a convenient object 54 | // representation. 55 | function ParseMetaData(countryCode, md) { 56 | var array = eval(md.replace(BACKSLASH, "\\\\")); 57 | md = ParseArray(array, 58 | META_DATA_ENCODING, 59 | { countryCode: countryCode }); 60 | regionCache[md.region] = md; 61 | return md; 62 | } 63 | 64 | // Parse string encoded format data into a convenient object 65 | // representation. 66 | function ParseFormat(md) { 67 | var formats = md.formats; 68 | if (!formats) { 69 | return null; 70 | } 71 | // Bail if we already parsed the format definitions. 72 | if (!(Array.isArray(formats[0]))) 73 | return; 74 | for (var n = 0; n < formats.length; ++n) { 75 | formats[n] = ParseArray(formats[n], 76 | FORMAT_ENCODING, 77 | {}); 78 | } 79 | } 80 | 81 | // Search for the meta data associated with a region identifier ("US") in 82 | // our database, which is indexed by country code ("1"). Since we have 83 | // to walk the entire database for this, we cache the result of the lookup 84 | // for future reference. 85 | function FindMetaDataForRegion(region) { 86 | // Check in the region cache first. This will find all entries we have 87 | // already resolved (parsed from a string encoding). 88 | var md = regionCache[region]; 89 | if (md) 90 | return md; 91 | for (var countryCode in dataBase) { 92 | var entry = dataBase[countryCode]; 93 | // Each entry is a string encoded object of the form '["US..', or 94 | // an array of strings. We don't want to parse the string here 95 | // to save memory, so we just substring the region identifier 96 | // and compare it. For arrays, we compare against all region 97 | // identifiers with that country code. We skip entries that are 98 | // of type object, because they were already resolved (parsed into 99 | // an object), and their country code should have been in the cache. 100 | if (Array.isArray(entry)) { 101 | for (var n = 0; n < entry.length; n++) { 102 | if (typeof entry[n] == "string" && entry[n].substr(2,2) == region) { 103 | if (n > 0) { 104 | // Only the first entry has the formats field set. 105 | // Parse the main country if we haven't already and use 106 | // the formats field from the main country. 107 | if (typeof entry[0] == "string") 108 | entry[0] = ParseMetaData(countryCode, entry[0]); 109 | let formats = entry[0].formats; 110 | let current = ParseMetaData(countryCode, entry[n]); 111 | current.formats = formats; 112 | return entry[n] = current; 113 | } 114 | 115 | entry[n] = ParseMetaData(countryCode, entry[n]); 116 | return entry[n]; 117 | } 118 | } 119 | continue; 120 | } 121 | if (typeof entry == "string" && entry.substr(2,2) == region) 122 | return dataBase[countryCode] = ParseMetaData(countryCode, entry); 123 | } 124 | } 125 | 126 | // Format a national number for a given region. The boolean flag "intl" 127 | // indicates whether we want the national or international format. 128 | function FormatNumber(regionMetaData, number, intl) { 129 | // We lazily parse the format description in the meta data for the region, 130 | // so make sure to parse it now if we haven't already done so. 131 | ParseFormat(regionMetaData); 132 | var formats = regionMetaData.formats; 133 | if (!formats) { 134 | return null; 135 | } 136 | for (var n = 0; n < formats.length; ++n) { 137 | var format = formats[n]; 138 | // The leading digits field is optional. If we don't have it, just 139 | // use the matching pattern to qualify numbers. 140 | if (format.leadingDigits && !format.leadingDigits.test(number)) 141 | continue; 142 | if (!format.pattern.test(number)) 143 | continue; 144 | if (intl) { 145 | // If there is no international format, just fall back to the national 146 | // format. 147 | var internationalFormat = format.internationalFormat; 148 | if (!internationalFormat) 149 | internationalFormat = format.nationalFormat; 150 | // Some regions have numbers that can't be dialed from outside the 151 | // country, indicated by "NA" for the international format of that 152 | // number format pattern. 153 | if (internationalFormat == "NA") 154 | return null; 155 | // Prepend "+" and the country code. 156 | number = "+" + regionMetaData.countryCode + " " + 157 | number.replace(format.pattern, internationalFormat); 158 | } else { 159 | number = number.replace(format.pattern, format.nationalFormat); 160 | // The region has a national prefix formatting rule, and it can be overwritten 161 | // by each actual number format rule. 162 | var nationalPrefixFormattingRule = regionMetaData.nationalPrefixFormattingRule; 163 | if (format.nationalPrefixFormattingRule) 164 | nationalPrefixFormattingRule = format.nationalPrefixFormattingRule; 165 | if (nationalPrefixFormattingRule) { 166 | // The prefix formatting rule contains two magic markers, "$NP" and "$FG". 167 | // "$NP" will be replaced by the national prefix, and "$FG" with the 168 | // first group of numbers. 169 | var match = number.match(SPLIT_FIRST_GROUP); 170 | if (match) { 171 | var firstGroup = match[1]; 172 | var rest = match[2]; 173 | var prefix = nationalPrefixFormattingRule; 174 | prefix = prefix.replace("$NP", regionMetaData.nationalPrefix); 175 | prefix = prefix.replace("$FG", firstGroup); 176 | number = prefix + rest; 177 | } 178 | } 179 | } 180 | return (number == "NA") ? null : number; 181 | } 182 | return null; 183 | } 184 | 185 | function NationalNumber(regionMetaData, number) { 186 | this.region = regionMetaData.region; 187 | this.regionMetaData = regionMetaData; 188 | this.nationalNumber = number; 189 | } 190 | 191 | // NationalNumber represents the result of parsing a phone number. We have 192 | // three getters on the prototype that format the number in national and 193 | // international format. Once called, the getters put a direct property 194 | // onto the object, caching the result. 195 | NationalNumber.prototype = { 196 | // +1 949-726-2896 197 | get internationalFormat() { 198 | var value = FormatNumber(this.regionMetaData, this.nationalNumber, true); 199 | Object.defineProperty(this, "internationalFormat", { value: value, enumerable: true }); 200 | return value; 201 | }, 202 | // (949) 726-2896 203 | get nationalFormat() { 204 | var value = FormatNumber(this.regionMetaData, this.nationalNumber, false); 205 | Object.defineProperty(this, "nationalFormat", { value: value, enumerable: true }); 206 | return value; 207 | }, 208 | // +19497262896 209 | get internationalNumber() { 210 | var value = this.internationalFormat ? this.internationalFormat.replace(NON_DIALABLE_CHARS, "") 211 | : null; 212 | Object.defineProperty(this, "internationalNumber", { value: value, enumerable: true }); 213 | return value; 214 | } 215 | }; 216 | 217 | // Check whether the number is valid for the given region. 218 | function IsValidNumber(number, md) { 219 | return md.possiblePattern.test(number); 220 | } 221 | 222 | // Check whether the number is a valid national number for the given region. 223 | function IsNationalNumber(number, md) { 224 | return IsValidNumber(number, md) && md.nationalPattern.test(number); 225 | } 226 | 227 | // Determine the country code a number starts with, or return null if 228 | // its not a valid country code. 229 | function ParseCountryCode(number) { 230 | for (var n = 1; n <= 3; ++n) { 231 | var cc = number.substr(0,n); 232 | if (dataBase[cc]) 233 | return cc; 234 | } 235 | return null; 236 | } 237 | 238 | // Parse an international number that starts with the country code. Return 239 | // null if the number is not a valid international number. 240 | function ParseInternationalNumber(number) { 241 | var ret; 242 | 243 | // Parse and strip the country code. 244 | var countryCode = ParseCountryCode(number); 245 | if (!countryCode) 246 | return null; 247 | number = number.substr(countryCode.length); 248 | 249 | // Lookup the meta data for the region (or regions) and if the rest of 250 | // the number parses for that region, return the parsed number. 251 | var entry = dataBase[countryCode]; 252 | if (Array.isArray(entry)) { 253 | for (var n = 0; n < entry.length; ++n) { 254 | if (typeof entry[n] == "string") 255 | entry[n] = ParseMetaData(countryCode, entry[n]); 256 | if (n > 0) 257 | entry[n].formats = entry[0].formats; 258 | ret = ParseNationalNumber(number, entry[n]) 259 | if (ret) 260 | return ret; 261 | } 262 | return null; 263 | } 264 | if (typeof entry == "string") 265 | entry = dataBase[countryCode] = ParseMetaData(countryCode, entry); 266 | return ParseNationalNumber(number, entry); 267 | } 268 | 269 | // Parse a national number for a specific region. Return null if the 270 | // number is not a valid national number (it might still be a possible 271 | // number for parts of that region). 272 | function ParseNationalNumber(number, md) { 273 | if (!md.possiblePattern.test(number) || 274 | !md.nationalPattern.test(number)) { 275 | return null; 276 | } 277 | // Success. 278 | return new NationalNumber(md, number); 279 | } 280 | 281 | // Parse a number and transform it into the national format, removing any 282 | // international dial prefixes and country codes. 283 | function ParseNumber(number, defaultRegion) { 284 | var ret; 285 | 286 | // Remove formating characters and whitespace. 287 | number = PhoneNumberNormalizer.Normalize(number); 288 | 289 | // We can't parse international access codes f there is no defaultRegion 290 | // or the defaultRegion is the global region. 291 | if ((!defaultRegion || defaultRegion === '001') && number[0] !== '+') 292 | return null; 293 | 294 | // Detect and strip leading '+'. 295 | if (number[0] === '+' || defaultRegion === '001') 296 | return ParseInternationalNumber(number.replace(LEADING_PLUS_CHARS_PATTERN, "")); 297 | 298 | // Lookup the meta data for the given region. 299 | var md = FindMetaDataForRegion(defaultRegion.toUpperCase()); 300 | 301 | // See if the number starts with an international prefix, and if the 302 | // number resulting from stripping the code is valid, then remove the 303 | // prefix and flag the number as international. 304 | if (md.internationalPrefix.test(number)) { 305 | var possibleNumber = number.replace(md.internationalPrefix, ""); 306 | ret = ParseInternationalNumber(possibleNumber) 307 | if (ret) 308 | return ret; 309 | } 310 | 311 | // This is not an international number. See if its a national one for 312 | // the current region. National numbers can start with the national 313 | // prefix, or without. 314 | if (md.nationalPrefixForParsing) { 315 | // Some regions have specific national prefix parse rules. Apply those. 316 | var withoutPrefix = number.replace(md.nationalPrefixForParsing, 317 | md.nationalPrefixTransformRule || ''); 318 | ret = ParseNationalNumber(withoutPrefix, md) 319 | if (ret) 320 | return ret; 321 | } else { 322 | // If there is no specific national prefix rule, just strip off the 323 | // national prefix from the beginning of the number (if there is one). 324 | var nationalPrefix = md.nationalPrefix; 325 | if (nationalPrefix && number.indexOf(nationalPrefix) == 0 && 326 | (ret = ParseNationalNumber(number.substr(nationalPrefix.length), md))) { 327 | return ret; 328 | } 329 | } 330 | ret = ParseNationalNumber(number, md) 331 | if (ret) 332 | return ret; 333 | 334 | // Now lets see if maybe its an international number after all, but 335 | // without '+' or the international prefix. 336 | ret = ParseInternationalNumber(number) 337 | if (ret) 338 | return ret; 339 | 340 | // If the number matches the possible numbers of the current region, 341 | // return it as a possible number. 342 | if (md.possiblePattern.test(number)) 343 | return new NationalNumber(md, number); 344 | 345 | // We couldn't parse the number at all. 346 | return null; 347 | } 348 | 349 | function IsPlainPhoneNumber(number) { 350 | if (typeof number !== 'string') { 351 | return false; 352 | } 353 | 354 | var length = number.length; 355 | var isTooLong = (length > MAX_PHONE_NUMBER_LENGTH); 356 | var isEmpty = (length === 0); 357 | return !(isTooLong || isEmpty || NON_DIALABLE_CHARS_ONCE.test(number)); 358 | } 359 | 360 | return { 361 | IsPlain: IsPlainPhoneNumber, 362 | Parse: ParseNumber, 363 | }; 364 | })(PHONE_NUMBER_META_DATA); 365 | --------------------------------------------------------------------------------