Viết lại hàm PHP chuyển dấu thanh cho từ tiếng Việt (v1.2)

Trước tôi có viết hàm chuyển dấu thanh, nó hoạt động tốt, nhưng mã rối, mà mã rối thì có vẻ vẫn không ổn rồi, nên hôm nay tôi sẽ viết lại.

Về quy tắc đặt dấu thanh chúng ta dùng kiểu cũ, phổ biến hơn. Chúng ta sẽ không tranh cãi về mặt học thuật liên quan đến ngôn ngữ học, mà chỉ tập trung vào việc chuyển dấu để thống nhất. Có thống nhất được vị trí dấu thì các xử lý liên quan đến tiếng Việt mới chính xác được, khi nào có khả năng tôi sẽ thử tìm hiểu xa hơn, còn hiện tại sẽ chỉ xử lý theo kiểu trên đã nói.

Ứng dụng thực tế: hàm chuẩn hóa dấu thanh này được tôi dùng để xử lý dữ liệu đầu vào họ tên, nhằm đưa ra các thống kê chuẩn xác hơn về họ tên người ở Việt Nam.


Quy tắc chung về thả dấu

  • Trong một từ tiếng Việt có dấu và nếu từ đó có ê hoặc ơ thì dấu luôn thuộc về ê hoặc ơ, đó là ưu tiên cao nhất, ví dụ nguyễn, thuở;
  • Trong một nguyên âm ba thì dấu sẽ nằm ở giữa, ví dụ hoài, cười,..
  • Trong một nguyên âm đôi mà sau đó không có phụ âm thì dấu đặt ở từ đầu, ví dụ tòa, hòa, cái, mái, cội, cói;
  • Trong một nguyên âm đôi mà sau đó có phụ âm thì dấu đặt ở nguyên âm thứ hai, ví dụ toàn, hoàn;
  • Với từ có hai nguyên âm mà âm đầu là giqu thì có ngoại lệ, dấu sẽ không được đặt ở i và u mà phải đặt ở nguyên âm tiếp theo, ví dụ: gió, quá, giỏi,…

Quy tắc về thả dấu này hiện cũng là quy tắc mặc định trên một số bộ gõ tiếng Việt, chẳng hạn như Unikey.

Để file chạy được nó cần require đến:


Cách dùng

Trước hết để kiểm tra xem một chuỗi tiếng Việt nhiều từ có cần điều chỉnh vị trí dấu không bạn dùng hàm vn_check_vow_bff_big($str)

Hàm kiểm tra trước rất quan trọng, nó giúp chúng ta hạn chế các thao tác thừa, và hạn chế cả lỗi nữa nếu như hàm mà chúng ta xây dựng chưa hoàn hảo.

Sau đó để sửa dấu cho nó bạn dùng hàm: vn_fix_vowel_string($str)

Cú pháp chung:

if (vn_check_vow_bff_big($str)) {
         vn_fix_vowel_string($str);
}

Hai cuối cùng vn_fix_vowel_string($str) kết hợp với hàm kiểm tra chính tả để cải thiện chất lượng kết quả.


Các hàm đặc thù, có sẵn của PHP được dùng bao gồm

  • preg_split để tách từng ký tự;
  • preg_match để đối sánh;
  • bin2hexhex2bin để chuyển đổi qua lại giữa ký tự và mã hex;
  • mb_substr để tách chuỗi, lấy chuỗi con hoặc một ký tự cụ thể;
  • in_array để xem một chuỗi có nằm trong mảng cần kiểm tra hay không, đôi khi nó là lựa chọn hay hơn preg_match;

Mã hoàn chỉnh

<?php
/* 
 * Fix the accent position of Vietnamese vowels v1.2
 * MIT License
 * Nguyen Duc Anh - freehost.page
 */


/////////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////


// tìm vị trí nguyên âm không dấu đầu tiên trong một từ
function vn_post_fnone_acc($str) {
    $navow = vna_vowel_lett(); // mảng nguyên âm đơn không dấu
    $chars = preg_split('//u', $str, -1, PREG_SPLIT_NO_EMPTY); // tách từng ký tự
    $j = 0; 
    $post = 0; // vị trí cần tìm, theo kiểm đếm con người
    foreach ($chars as $char) { // chuyển nó thành từng ký tự
        $j++;
        if (in_array($char,$navow)) {$post = $j; break;} // lấy được vị trí đầu tiên
    }

return $post;    
}

/////////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////

// tìm vị trí nguyên âm không dấu cuối cùng trong một từ
function vn_post_lnone_acc($str) {
    $navow = vna_vowel_lett(); // mảng nguyên âm đơn không dấu
    $chars = preg_split('//u', $str, -1, PREG_SPLIT_NO_EMPTY); // tách từng ký tự
    $j = 0; 
    $post = 0; // vị trí cần tìm, theo kiểm đếm con người
    foreach ($chars as $char) { // chuyển nó thành từng ký tự
        $j++;
        if (in_array($char,$navow)) {$post = $j;} // lấy được vị trí cuối cùng
    }

return $post;     
}

/////////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////

// tìm vị trí nguyên âm có dấu trong một từ
function vn_post_acc($str) {
    $avow = vna_acc_char_array(); // mảng nguyên âm đơn có dấu
    $chars = preg_split('//u', $str, -1, PREG_SPLIT_NO_EMPTY); // tách từng ký tự
    $j = 0; 
    $post = 0; // vị trí cần tìm, theo kiểm đếm con người
    foreach ($chars as $char) { // chuyển nó thành từng ký tự
        $j++;
        if (in_array($char,$avow)) {$post = $j;} // lấy được vị trí nguyên âm có dấu
    }

return $post;     
}


/////////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////


// đảo dấu 2 nguyên âm đứng cạnh nhau
function vn_acc_turn_lr($dvow){ // chuyển dấu sang trái hoặc phải 
    $vowo = mb_substr($dvow, 0, 1); // nguyên âm thứ nhất 
    $vowt = mb_substr($dvow, 1, 1); // nguyên âm thứ hai 
    
    $hexo = bin2hex(rarely_hex_convert($vowo)); // hex nguyên âm thứ nhất
    $hext = bin2hex(rarely_hex_convert($vowt)); // hex nguyên âm thứ hai
    
    $acc = vna_acc_char_array(); // lấy mảng nguyên âm có dấu
    $new_word_double = $dvow; // gán mặc định dự phòng
    
    if (in_array($vowt,$acc)) { // chuyển dấu từ phải sang trái
        // dấu là 4 ký tự ở cuối mã hex thứ hai
        $len_hext = vn_num_char($hext); // lấy độ dài ký tự của mã hex của ký tự thứ hai
        $top_hext = mb_substr($hext,0,-4); // lấy chuỗi đầu hext
        $bottom_hext = mb_substr($hext,$len_hext-4, 4); // lấy 4 ký tự cuối
        $new_hexo = $hexo.$bottom_hext; // đã đảo thành công

        $new_hex_double =  $new_hexo.$top_hext; // tạo ra được mã hex cho 2 ký tự mới
        $new_word_double = pop_hex_convert(hex2bin($new_hex_double)); // chuyển thành mã hóa phổ thông và chuyển thành ký tự
    }
    
    if (in_array($vowo,$acc)) { // chuyển dấu từ trái sang phải
    // dấu là 4 ký tự ở cuối mã $hex đầu tiên
        $len_hexo = vn_num_char($hexo); // lấy độ dài ký tự của mã hex của ký tự đầu tiên
        $top_hexo = mb_substr($hexo,0,-4); // lấy chuỗi đầu hexo
        $bottom_hexo = mb_substr($hexo,$len_hexo-4, 4); // lấy 4 ký tự cuối
        $new_hext = $hext.$bottom_hexo; // đã đảo thành công

        $new_hex_double =  $top_hexo.$new_hext; // tạo ra được mã hex cho 2 ký tự mới
        $new_word_double = pop_hex_convert(hex2bin($new_hex_double)); // chuyển thành mã hóa phổ thông và chuyển thành ký tự
    }
    
return $new_word_double; // đảo dấu thành công
}



/////////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////



// đầu vào ký tự thường, mã hóa phổ biến
function do_nothing_eeow($str) { // kiểm tra xem ký tự có dấu liên quan đến ơ và ê có mặt trong từ không
    $rs = 0;
    // nếu có mặt thì không phải thao tác chuyển dấu, ê và ơ có dấu được ưu tiên
    $priority_eeow = array("ớ","ờ","ở","ỡ","ợ","ế","ề","ể","ễ","ệ");
           foreach ($priority_eeow as $eeow) {
               $pt='/'.$eeow.'/';
               if (preg_match($pt, $str)) {$rs = 1; break;}
           }
           
return $rs; // nếu kết quả trả về 1 nghĩa là sẽ không cần thao tác chuyển dấu nào
}



/////////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////



function detect_excep_eeow($str) { // phát hiện các từ có ê và ơ
    $rs = 0;
    // num_acc_char = 1 và có ê ơ bên trong
    $ptee = '/ê/'; $ptow = '/ơ/'; // tạo các mẫu so sánh trong hàm preg_match
    if (preg_match($ptee, $str)) {$rs = 1;} // phát hiện có ê
    if (preg_match($ptow, $str)) {$rs = 1;} // phát hiện có ơ

return $rs; // trả về 1 tức là đây là từ có ê và ơ    
}



/////////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////



function fix_excep_eeow($str){ // đầu vào là từ có dấu và có ê hoặc ơ, do vậy ê hoặc ơ sẽ cần lấy dấu này
    // sử dụng hàm detect_excep_eeow($str) để phát hiện ê và ơ
    $str2 = preg_split('//u', $str, -1, PREG_SPLIT_NO_EMPTY); // tách từng ký tự
    $timbre = vna_hex_timbre(); // các mã hex có dấu
    $new_hex_char = ""; // mã hex_char mới
    $acc = ""; // dấu cần phải tìm, $timbre
    
    foreach ($str2 as $char) {
        $hex_char = bin2hex(rarely_hex_convert($char)); // chuyển sang dạng mã hóa không phổ biến có bao gồm mã hex dấu bên trong
            foreach ($timbre as $tim) { // tách mảng dấu để lấy từng dấu
                $pt = '/'.$tim.'/';
                if (preg_match($pt, $hex_char)) { // tìm dấu trong từng ký tự
                    $acc = $tim; // phát hiện dấu thì gán vào
                    $hex_char = mb_substr($hex_char,0,-4); // cắt dấu của từ có dấu 
                }
            }
        if ($char == 'ê') {$hex_char = $hex_char.$acc;} // gán thêm dấu cho ê
        if ($char == 'ơ') {$hex_char = $hex_char.$acc;} // gán thêm dấu chơ ơ
        $new_hex_char = $new_hex_char.$hex_char; // tạo thành mã hexchar mới
    }
    
    $rs = pop_hex_convert(hex2bin($new_hex_char)); // chuyển mã hex và chuyển mã hóa để tạo thành từ mới
    
return $rs; // trả về kết quả    
}



/////////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////



function giqu($str) { // ngoại lệ dành cho gi và qu trong từ có nguyên âm đôi, dấu vẫn đặt ở nguyên âm cuối
    $rs = 0; 
    $two_first_char = mb_substr($str,0,2); // lấy 2 ký tự đầu tiên
    if ($two_first_char == "gi" || $two_first_char == "qu") {$rs = 1;}

return $rs;    
}



/////////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////



function detect_excep_giqu($str){ // phát hiện các trường hợp như gío và qúa
    $rs = 0;
    $two_first_char = mb_substr($str,0,2);
    
    $priority_i = array("ì","í","ỉ","ĩ","ị"); // mảng ký tự i có dấu
    $priority_u = array("ù","ú","ủ","ũ","ụ"); // mảng ký tự u có dấu
    
    foreach ($priority_i as $pi) {
        $gi = "g".$pi; // nối vào thành gì, gí, gị,...
        if ($two_first_char == $gi) {$rs = 1; break;} // ngắt ngay để đỡ mất thời gian
    }
    
    if ($rs == 0) { // nếu rs = 1 rồi thì không cần phải làm gì nữa
        foreach ($priority_u as $pu) {
            $qu = "q".$pu; // nối vào thành qù, qú, qụ,...
            if ($two_first_char == $qu) {$rs = 1; break;}
        }
    }
    
return $rs;    
}



////////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////



function fix_excep_giqu($str){
    // sử dụng hàm detect_excep_giqu($str) để phát hiến gí và qú
    $chars = preg_split('//u', $str, -1, PREG_SPLIT_NO_EMPTY); // tách từng ký tự
    $timbre = vna_hex_timbre(); // các mã hex có dấu
    $snv = vna_vowel_lett(); // mảng nguyên âm không dấu
    $new_hex_char = ""; // mã hex_char mới
    $acc = ""; // dấu cần phải tìm, $timbre
    $j = 0;
    foreach ($chars as $char) {
        $hex_char = bin2hex(rarely_hex_convert($char)); // chuyển sang dạng mã hóa không phổ biến có bao gồm mã hex dấu bên trong
            foreach ($timbre as $tim) { // tách mảng dấu để lấy từng dấu
                $pt = '/'.$tim.'/';
                if (preg_match($pt, $hex_char)) { // tìm dấu trong từng ký tự
                    $acc = $tim; // phát hiện dấu thì gán vào
                    $hex_char = mb_substr($hex_char,0,-4); // cắt dấu của từ có dấu 
                }
            }
        if (in_array($char,$snv) && $j < 1) {$j++; $hex_char = $hex_char.$acc;} // gán thêm dấu cho nguyên âm không dấu đầu tiên tìm được
        
        $new_hex_char = $new_hex_char.$hex_char; // tạo thành mã hexchar mới
    }
    
    $rs = pop_hex_convert(hex2bin($new_hex_char)); // chuyển mã hex và chuyển mã hóa để tạo thành từ mới
    
return $rs; // trả về kết quả      
} 



////////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////



// kiểm tra trước xem của nên sửa lỗi vị trí dấu nguyên âm hay không
// kiểm tra dành cho một từ
function vn_check_vow_bff($str) {
    $doing = 0; // mặc định là không sửa
    // từ có một nguyên âm có dấu và ít nhất một nguyên âm không dấu
    // mới cần xử lý tiếp
    if (vn_num_acc_char($str) == 1 && vn_num_none_acc_vowel($str) > 0) {
        $num_char = vn_num_char($str); // lấy độ dài ký tự
        $post_fnone_acc = vn_post_fnone_acc($str); // vị trí nguyên âm đầu
        $post_lnone_acc = vn_post_lnone_acc($str); // vị trí nguyên âm cuối
        $post_acc = vn_post_acc($str); // vị trí nguyên âm có dấu
        
        if (vn_num_none_acc_vowel($str)==1) {
            if ($num_char == $post_acc) {$doing = 1;}
            if ($num_char > $post_lnone_acc && $post_lnone_acc > $post_acc) {$doing = 1;}
        }
        
        if (vn_num_none_acc_vowel($str)==2) {
            if ($post_acc > $post_lnone_acc) {$doing = 1;}
            if ($post_fnone_acc > $post_acc) {$doing = 1;}
        }
        
        // từ có dấu nhưng không thuộc về ê hoặc ơ
        if (detect_excep_eeow($str)) {$doing = 1;}
        
        // từ dạng gío, gỉoi, qúan
        if (detect_excep_giqu($str)) {$doing = 1;}
        
        // các ngoại lệ
        if (do_nothing_eeow($str)) {$doing = 0;} // ê, ơ có dấu không làm gì cả
        if (giqu($str)) {$doing = 0;} // nếu có gió và quán như này thì không làm gì cả
    }

return $doing;    
}



////////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////



// kiểm tra trước dành cho chuỗi nhiều từ
function vn_check_vow_bff_big($str){
    $rs = 0;
    $str2 = pop_hex_convert($str); // chuyển về mã hóa tiêu chuẩn và ký tự thường
    $words = mb_split(' ', $str2); // tách ra thành từng từ
        foreach ($words as $word) {
            if (vn_check_vow_bff($word)) {$rs = 1; break;}
            // thấy có từ sai chính tả thì ngắt không cần kiểm tra tiếp
    }
    
return $rs;    
}



////////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////



// chỉ áp dụng cho một từ
function vn_fix_post_vowel($strx) { // sửa dấu với từ có 2, 3 nguyên âm
    $rs = $strx; // gán về giá trị ban đầu để dự phòng trường hợp không cần chỉnh
    $str = pop_hex_convert($strx); // chuyển về ký tự thường và mã hóa phổ thông
    $do_some_thing = 1; // mặc định sẽ làm điều gì đó
    
    // các ngoại lệ
    if (do_nothing_eeow($str)) {$do_some_thing = 0;} // ê, ơ có dấu không làm gì cả
    if (giqu($str)) {$do_some_thing = 0;} // nếu có gi và qu như này thì không làm gì cả
    
    if (detect_excep_eeow($str)) { // có dấu và ê, ơ không dấu có trong từ
        $rs = fix_excep_eeow($str); // nhường dấu cho ê hoặc ơ
        $do_some_thing = 0; // sau đó không làm gì cả
    }
    
    if (detect_excep_giqu($str)) { // nếu là gí và qú thì sửa lại
        $rs = fix_excep_giqu($str); // chỉnh lại dấu
        $do_some_thing = 0; // sau đó không làm gì cả
    } 
    
    $num_char = vn_num_char($str); // lấy độ dài ký tự
    $post_fnone_acc = vn_post_fnone_acc($str); // vị trí nguyên âm đầu
    $post_lnone_acc = vn_post_lnone_acc($str); // vị trí nguyên âm cuối
    $post_acc = vn_post_acc($str); // vị trí nguyên âm có dấu
    
    if ($do_some_thing == 1 && vn_num_none_acc_vowel($str)==1) { // sau khi không dính vào các ngoại lệ thì làm như bên dưới
        // có một nguyên âm không dấu
        
        if ($num_char == $post_acc) { // nguyên âm có dấu ở vị trí cuối cùng
            // tức là cần đảo vị trí
            $char_acc = mb_substr($str,$post_acc-1,1); // lấy nguyên âm có dấu
            $char_none_acc = mb_substr($str,$post_acc-2,1); // lấy nguyên âm không dấu
            $old_word_double = $char_none_acc.$char_acc; // cặp cũ
            $new_word_double = vn_acc_turn_lr($old_word_double); // cặp mới
            
            $pt = '/'.$old_word_double.'/';
            $rs = preg_replace($pt, $new_word_double, $str); // thay thế
        }
        
        if ($num_char > $post_lnone_acc && $post_lnone_acc > $post_acc) {
            // có phụ âm cuối, và nguyên âm không dấu đứng sau nguyên âm có dấu
            // cũng cần chỉnh vị trí
            $char_acc = mb_substr($str,$post_acc-1,1); // lấy nguyên âm có dấu
            $char_none_acc = mb_substr($str,$post_acc,1); // lấy nguyên âm không dấu
            $old_word_double = $char_acc.$char_none_acc; // cặp cũ
            $new_word_double = vn_acc_turn_lr($old_word_double); // cặp mới
            
            $pt = '/'.$old_word_double.'/';
            $rs = preg_replace($pt, $new_word_double, $str); // thay thế            
        }
    }
    
    if ($do_some_thing == 1 && vn_num_none_acc_vowel($str)==2) {// nguyên âm ba
        // chỉ cần sửa khi từ có dấu không nằm ở giữa
        if ($post_acc > $post_lnone_acc) { // nguyên âm có dấu ở vị trí cuối cùng trong các nguyên âm
            $char_acc = mb_substr($str,$post_acc-1,1); // lấy nguyên âm có dấu
            $char_none_acc = mb_substr($str,$post_acc-2,1); // lấy nguyên âm không dấu
            $old_word_double = $char_none_acc.$char_acc;
            $new_word_double = vn_acc_turn_lr($old_word_double);
            
            $pt = '/'.$old_word_double.'/';
            $rs = preg_replace($pt, $new_word_double, $str); // thay thế
        }
        
        if ($post_fnone_acc > $post_acc) { // nguyên âm có dấu ở vị trí đầu tiên trong các nguyên âm
            $char_acc = mb_substr($str,$post_acc-1,1); // lấy nguyên âm có dấu
            $char_none_acc = mb_substr($str,$post_acc,1); // lấy nguyên âm không dấu thứ nhất
            $old_word_double = $char_acc.$char_none_acc; // cặp cũ
            $new_word_double = vn_acc_turn_lr($old_word_double); // cặp mới
            
            $pt = '/'.$old_word_double.'/';
            $rs = preg_replace($pt, $new_word_double, $str); // thay thế
        }
    }
    
return $rs; // trả về kết quả   
}



////////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////



// chỉnh vị trí dấu cho chuỗi nhiều từ
function vn_fix_vowel_string($str){
    $rs = '';
    $str2 = pop_hex_convert($str); // chuyển về mã hóa tiêu chuẩn và ký tự thường
    
    if (vn_simple_spell_big($str2)) { // kiểm tra chính tả
        $words = mb_split(' ', $str2); // tách ra thành từng từ
        foreach ($words as $word) {
            if ($word!=NULL){
                $rs.=vn_fix_post_vowel($word).' ';
            }    
        }
    }
    
    if ($rs == '') {$rs = $str;} // dự phòng
        
return $rs;    
}

////////////////////////////////////////////////////////////////////////End code

Giải thích mã

Tôi chú thích khá nhiều trong mã để các bạn nào muốn tìm hiểu có thể thấy dễ dàng hơn.

Để giải quyết bài toán liên quan đến thả dấu tôi xây dựng các hàm nhỏ giải quyết từng vấn đề đơn lẻ một:

  • Tạo các hàm con để phát hiện các ưu tiên ngoại lệ cho ê, ơ, gi, qu;
  • Tạo các hàm con để đếm số lượng nguyên âm có dấu và không dấu;
  • Tạo các hàm con để biết được vị trí nguyên âm có dấu và không dấu;
  • Tạo hàm đảo dấu;
  • Thực hiện việc so sánh vị trí giữa nguyên âm có dấu, không dấu và số lượng ký tự của từ để điều chỉnh dấu cho phù hợp với quy tắc;