Форматирование телефонных номеров на PHP

PHP
Возникла задача автоматического форматирования телефонных номеров в виде страна (город) номер, и первым делом я обратился к существующим решениям.
К сожалению, оказалось, что все найденные решения основываются на обычном подгоне строки под пользовательский формат, имея ограниченную область применения и ошибки при выходе за ее пределы.

Для начала приведу обзор найденных решений. Тем, кому это не интересно, рекомендую прокрутить ниже до заголовка «Форматы телефонных номеров» — там уже представлен мой вариант разбора номера с ссылкой на код.

Всеуничтожающий примитив

(Найденное решение. Мое ниже)
Первое, на что я наткнулся — были сообщения на форумах и банки скриптов, предлагающие решения следующего плана:
<?
function phone_number($sPhone){
$sPhone = ereg_replace("[^0-9]",'',$sPhone);
if(strlen($sPhone) != 10) return(False);
$sArea = substr($sPhone, 0,3);
$sPrefix = substr($sPhone,3,3);
$sNumber = substr($sPhone,6,4);
$sPhone = "(".$sArea.")".$sPrefix."-".$sNumber;
return($sPhone);
}
?>

Один из простых вариантов шустрого форматирования телефонных номеров, но каждое такое решение ориентировано на телефонные номера из конкретной локальной зоны и не является решением задачи.

Форматирование с помощью sscanf

(Найденное решение. Мое ниже)
function formatPhone($phone) {
if (empty($phone)) return "";
if (strlen($phone) == 7)
sscanf($phone, "%3s%4s", $prefix, $exchange);
else if (strlen($phone) == 10)
sscanf($phone, "%3s%3s%4s", $area, $prefix, $exchange);
else if (strlen($phone) > 10)
if(substr($phone, 0,1)=='1') {
sscanf($phone, "%1s%3s%3s%4s", $country, $area, $prefix, $exchange);
}
else{
sscanf($phone, "%3s%3s%4s%s", $area, $prefix, $exchange, $extension);
}
else
return «unknown phone format: $phone»;
$out = "";
$out .= isset($country)? $country.' ': '';
$out .= isset($area)? '('. $area. ') ': '';
$out .= $prefix. '-'. $exchange;
$out .= isset($extension)? ' x'. $extension: '';
return $out;
}

Не смотря на простое решение, эта функция уже умеет форматировать номера длиной 7, 10 и более цифр, но попадись ей номер из российской глубинки, она подавится и выдаст ошибочный результат.

Symfony, lib/helpers/PhoneHelper.php, format_phone

(Найденное решение. Мое ниже)
<?php
function format_phone($phone = '', $convert = false, $trim = true)
{
// If we have not entered a phone number just return empty
if (empty($phone)) {
return '';
}

// Strip out any extra characters that we do not need only keep letters and numbers
$phone = preg_replace("/[^0-9A-Za-z]/", "", $phone);

// Do we want to convert phone numbers with letters to their number equivalent?
// Samples are: 1-800-TERMINIX, 1-800-FLOWERS, 1-800-Petmeds
if ($convert == true) {
$replace = array('2'=>array('a','b','c'),
'3'=>array('d','e','f'),
'4'=>array('g','h','i'),
'5'=>array('j','k','l'),
'6'=>array('m','n','o'),
'7'=>array('p','q','r','s'),
'8'=>array('t','u','v'), '9'=>array('w','x','y','z'));

// Replace each letter with a number
// Notice this is case insensitive with the str_ireplace instead of str_replace
foreach($replace as $digit=>$letters) {
$phone = str_ireplace($letters, $digit, $phone);
}
}

// If we have a number longer than 11 digits cut the string down to only 11
// This is also only ran if we want to limit only to 11 characters
if ($trim == true && strlen($phone)>11) {
$phone = substr($phone, 0, 11);
}

// Perform phone number formatting here
if (strlen($phone) == 7) {
return preg_replace("/([0-9a-zA-Z]{3})([0-9a-zA-Z]{4})/", "$1-$2", $phone);
} elseif (strlen($phone) == 10) {
return preg_replace("/([0-9a-zA-Z]{3})([0-9a-zA-Z]{3})([0-9a-zA-Z]{4})/", "($1) $2-$3", $phone);
} elseif (strlen($phone) == 11) {
return preg_replace("/([0-9a-zA-Z]{1})([0-9a-zA-Z]{3})([0-9a-zA-Z]{3})([0-9a-zA-Z]{4})/", "$1($2) $3-$4", $phone);
}

// Return original phone if not 7, 10 or 11 digits long
return $phone;
}
?>

Функция позволяет не только форматировать в XXX-XXXX, (XXX) XXX-XXXX и X (XXX) XXX-XXXX, но и конвертировать номера, написанные цифрами. Ограниченность функции в форматировании номеров длиной 7, 10 и 11 символов никак не подходит.

Форматы телефонных номеров

Из вики-статьи видно, что никакого простого и удобного паттерна для быстрого форматирования всех номеров не существует. Коды стран регистрируются, подобно доменным зонам, а коды городов — остаются на совести каждой из стран.

Другими словами, маршрутизация звонков идет по маске, начиная с кода страны: звонок, направленный в конкретную страну далее пробивает себе маршрут в соответствии с кодами области, города, района и т.д. начиная с самой левой цифры, пока последнее звено не перебросит его на конкретный телефонный/факсовый аппарат. Проблема усложняется еще и тем, что коды городов внутри стран точно так же не поддаются единой сквозной стандартизации, т.е. в худшем из вариантов для правильного форматирования номеров придется использовать двумерный массив с кодами стран и их городов.

На самом деле, все оказалось не так страшно. В каждой стране можно разделить все коды городов на две части: на те, что в большинстве своем совпадают по длине, и все остальные. Этого достаточно, чтобы резко сократить область перебора кодов при сравнении. Т.е. можно создать массив из данных по каждой стране вида:
<?
$data = Array(
'Код страны'=>Array(
'name'=>'Имя страны', // для удобства. Не будет использоваться.
'cityCodeLength'=> обычная_длина_кода_города_для_этой_страны,
'exceptions'=>Array(коды_городов_исключения),
)
);
?>
Затем провести предварительную обработку данных, дополнив его полями, сужающими область перебора, exceptions_max и exceptions_min — максимальной и минимальной длиной кода городов-исключений, соответственно. Также необходимо учесть страны, в которых коды городов начинаются на 0 — отразим эту «особенность» полем zeroHack. Как пример:
<?
$data = Array(
'886'=>Array(
'name'=>'Taiwan',
'cityCodeLength'=>1,
'zeroHack'=>false,
'exceptions'=>Array(89,90,91,92,93,96,60,70,94,95),
'exceptions_max'=>2,
'exceptions_min'=>2
),
);
?>
После этого возьмем подходящие участки кода из решений выше и сделаем функцию форматирования:
<?
function phone($phone = '', $convert = true, $trim = true)
{
global $phoneCodes; // только для примера! При реализации избавиться от глобальной переменной.
if (empty($phone)) {
return '';
}
// очистка от лишнего мусора с сохранением информации о «плюсе» в начале номера
$phone=trim($phone);
$plus = ($phone[ 0] == '+');
$phone = preg_replace("/[^0-9A-Za-z]/", "", $phone);
$OriginalPhone = $phone;

// конвертируем буквенный номер в цифровой
if ($convert == true && !is_numeric($phone)) {
$replace = array('2'=>array('a','b','c'),
'3'=>array('d','e','f'),
'4'=>array('g','h','i'),
'5'=>array('j','k','l'),
'6'=>array('m','n','o'),
'7'=>array('p','q','r','s'),
'8'=>array('t','u','v'),
'9'=>array('w','x','y','z'));

foreach($replace as $digit=>$letters) {
$phone = str_ireplace($letters, $digit, $phone);
}
}

// заменяем 00 в начале номера на +
if (substr($phone, 0, 2)==«00»)
{
$phone = substr($phone, 2, strlen($phone)-2);
$plus=true;
}

// если телефон длиннее 7 символов, начинаем поиск страны
if (strlen($phone)>7)
foreach ($phoneCodes as $countryCode=>$data)
{
$codeLen = strlen($countryCode);
if (substr($phone, 0, $codeLen)==$countryCode)
{
// как только страна обнаружена, урезаем телефон до уровня кода города
$phone = substr($phone, $codeLen, strlen($phone)-$codeLen);
$zero=false;
// проверяем на наличие нулей в коде города
if ($data['zeroHack'] && $phone[ 0]=='0')
{
$zero=true;
$phone = substr($phone, 1, strlen($phone)-1);
}

$cityCode=NULL;
// сначала сравниваем с городами-исключениями
if ($data['exceptions_max']!= 0)
for ($cityCodeLen=$data['exceptions_max']; $cityCodeLen>=$data['exceptions_min']; $cityCodeLen--)
if (in_array(intval(substr($phone, 0, $cityCodeLen)), $data['exceptions']))
{
$cityCode = ($zero? «0»: "").substr($phone, 0, $cityCodeLen);
$phone = substr($phone, $cityCodeLen, strlen($phone)-$cityCodeLen);
break;
}
// в случае неудачи с исключениями вырезаем код города в соответствии с длиной по умолчанию
if (is_null($cityCode))
{
$cityCode = substr($phone, 0, $data['cityCodeLength']);
$phone = substr($phone, $data['cityCodeLength'], strlen($phone)-$data['cityCodeLength']);
}
// возвращаем результат
return ($plus? "+": "").$countryCode.'('.$cityCode.')'.phoneBlocks($phone);
}
}
// возвращаем результат без кода страны и города
return ($plus? "+": "").phoneBlocks($phone);
}

// функция превращает любое число в строку формата XX-XX-... или XXX-XX-XX-... в зависимости от четности кол-ва цифр
function phoneBlocks($number){
$add='';
if (strlen($number)%2)
{
$add = $number[ 0];
$add .= (strlen($number)<=5? "-": "");
$number = substr($number, 1, strlen($number)-1);
}
return $add.implode("-", str_split($number, 2));
}

// тесты
echo phone("+38 (044) 226-22-04")."<br />";
echo phone(«0038 (044) 226-22-04»)."<br />";
echo phone("+79263874814")."<br />";
echo phone(«4816145»)."<br />";
echo phone("+44 (0) 870 770 5370")."<br />";
echo phone(«0044 (0) 870 770 5370»)."<br />";
echo phone("+436764505509")."<br />";
echo phone("(+38-048) 784-15-46 ")."<br />";
echo phone("(38-057) 706-34-03 ")."<br />";
echo phone("+38 (044) 244 12 01 ")."<br />";
?>

, где global $phoneCodes; — тот самый массив с информацией по всем странам.

Выведет
+380(44)226-22-04<br/>+380(44)226-22-04<br/>+7(926)387-48-14<br/>481-61-45<br/>+44(0870)770-53-70<br/>+44(0870)770-53-70<br/>+43(6764)50-55-09<br/>380(4878)415-46<br/>380(5770)634-03<br/>+380(44)244-12-01

Функция полностью решает поставленную задачу.
Из недостатков функции следует отметить отсутствие анализа медленных участков с целью оптимизаци, а также обработки телефонных номеров, где есть код города, но нет кода страны (в этом случае достаточно бить на блоки функцией phoneBlocks или воспользоваться одним из решений выше). При использовании ее в какой-либо реализации необходимо заменить глобальную переменную на ссылку в параметре, а также можно доработать или заменить формат вывода, за который отвечает функция phoneBlocks.

Самое интересное

Используя информацию с сайтов:
http://www.mtt.ru/info/def/index.wbp
http://www.hella.ru/code/codeuro.htm
http://www.scross.ru/guide/phone-global/
я собрал массив данных по всем представленным странам, включая города-исключения, флаги zeroHack, а также коды мобильных сетей. Код можно загрузить здесь.

Быстродействие

Вопреки всем самым пессимистичным ожиданиям, код отрабатывает 10.000 номеров менее чем за 2 секунды.

UPD Готовятся поправки:
  1. поддержка паттернов форматирования, принятых внутри конкретных стран («локально-принятые» нормы отображения номеров);
  2. добавление флага для указания, относительно какой страны выполнять форматирование номера;
  3. добавление параметра для указания формата вывода (в случае личных предпочтений и исключений);
  4. поддержка нелатинских буквенных номеров
  5. определение сотовых номеров и замена скобок на пробелы
UPD: Архив пропал с сервера, выложил на https://github.com/mrXCray/PhoneCodesСкоро будет обновление по поправкам выше + бонус.
Tags:phpтелефонформатирование
Hubs: PHP
+59
47.2k 218
Comments 97
Комплексное обучение PHP
April 19, 202120,000 ₽Loftschool
PHP. Уровень 1. Основы создания сайтов
February 1, 202115,990 ₽Специалист.ру
Product Manager IT-проектов
January 28, 202160,000 ₽OTUS
Тренажер product-менеджера
January 28, 202128,500 ₽SkillFactory
Team Lead 2.0
January 28, 202190,000 ₽OTUS

Top of the last 24 hours