Viết hàm PHP kiểm tra lỗi chính tả đơn giản cho tiếng Việt (v1.2)

Đã có phiên bản plus của hàm kiểm tra chính tả tiếng Việt, cho độ chính xác cải thiện thêm khoảng 20% so với hàm trong bài này.

Trong bài viết này tôi sẽ trình bày và viết hàm PHP phát hiện lỗi chính tả ở mức độ đơn giản, dù không đạt kết quả ấn tượng, nó vẫn loại trừ được khá nhiều trường hợp sai lỗi chính tả điển hình.

Hàm này cần có các require sau trên cùng file để chạy:

Tiền tố của hàm là vn giống với tiền tố chung tôi dùng để xử lý chuỗi tiếng Việt.


Các nguyên tắc chung

  • Một mình phụ âm không tạo thành từ, do vậy một từ tiếng Việt có nghĩa phải có ít nhất một nguyên âm;
  • Từ tiếng Việt có tối đa 3 nguyên âm. Một số từ nhập từ nước ngoài sau đó chuyển sang âm tiếng Việt có thể có số lượng lớn hơn, nhưng số từ này rất ít, và chúng ta có thể tạo một ngoại lệ sau;
  • Các nguyên âm phải đứng cạnh nhau. Cũng có một số từ nước ngoài nhập vào tiếng Việt, ví dụ ba-toong có các nguyên âm tách xa nhau, nhưng số lượng này cũng rất ít, và có thể tạo mảng ngoại lệ sau;
  • Ngoài từ nghiêng có 7 ký tự, tất cả các từ đơn tiếng Việt chỉ có tối đa 6 ký tự;
  • Một từ tiếng Việt chỉ có tối đa một dấu thanh;
  • Không có các ký tự nước ngoài là w, j, z, f trong từ;
  • Kết hợp với các ràng buộc khác liên quan đến phụ âm đầu, phụ âm cuối, nguyên âm đôi, nguyên âm ba, chúng ta có thể có những đánh giá tương đối chặt để yêu cầu một từ đúng chính tả;

Mã hoàn chỉnh

Cách dùng:

  • Nếu bạn muốn kiểm tra chính tả chuỗi $str gồm nhiều từ, sử dụng hàm vn_simple_spell_big($str)
  • Hàm trên trả về kết quả 0 nếu sai chính tả, 1 nếu đúng. Khả năng kiểm tra sai của nó tốt hơn là kiểm tra đúng;
  • Nếu bạn muốn kiểm tra chính tả chuỗi $str chỉ bao gồm một từ thì sử dụng hàm vn_simple_spell_small($str)

Phần giải thích các câu lệnh trong hàm có ở trong mã, và tôi có giải thích thêm bên cuối (cập nhật sau).

<?php

/* 
 * PHP functions to check Vietnamese spelling v1.2
 * MIT License
 * Nguyen Duc Anh - freehost.page
 */



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



// chỉ áp dụng với một từ
function vn_vowel_next_other($str) { // kiểm tra các nguyên âm có đứng cạnh nhau hay không
    // kiểm tra luôn số lượng nguyên âm được phép chỉ nằm trong khoảng từ 1 - 3
    $rs = 0; // mặc định là không đứng cạnh nhau
    // nguyên âm có dấu (60) và không dấu (12) gồm 72 ký tự đơn
    // cộng mảng các nguyên âm không dấu và có dấu để ra mảng nguyên âm chung
    $vowel = array_merge(vna_acc_char_array(), vna_vowel_lett());
    $str2 = pop_hex_convert($str); // chuyển về dạng mã hóa phổ biến, ký tự thường
    $str3 = preg_split('//u', $str2, -1, PREG_SPLIT_NO_EMPTY); // tách từng ký tự
    
    $j = 0;
        foreach ($str3 as $char) {
                $j++;
                if (in_array($char, $vowel)) {$post_first_vowel = $j; break;} // ngắt vòng lặp, tìm được vị trí nguyên âm đầu tiên
        }
        
    // tính tổng số nguyên âm, gồm cả có dấu lẫn không dấu        
    $k = 0;
    $total_number_vowels = vn_num_none_acc_vowel($str2) + vn_num_acc_char($str2);
    
    if ($total_number_vowels == 1 || $total_number_vowels == 0) {$rs = 1;} // trường hợp này luôn đúng với hàm kiểm tra này
    
    if ($total_number_vowels == 2) { // tức là có tổng hai nguyên âm tất cả
        foreach ($str3 as $char2) {
                $k++;
                if (in_array($char2, $vowel)) {$post_two_vowel = $k;} // tìm được vị trí nguyên âm cuối cùng, tức nguyên âm thứ 2
        }
        
        if ($post_two_vowel == ($post_first_vowel + 1)) {$rs = 1;}
    }
    
    $m = 0;
    if ($total_number_vowels == 3) { // tức là có tổng ba nguyên âm tất cả
        foreach ($str3 as $char3) {
                $m++;
                if (in_array($char3, $vowel)) {$post_last_vowel = $m;} // tìm được vị trí nguyên âm cuối cùng, tức nguyên âm thứ 3
        }
        
        if ($post_last_vowel == ($post_first_vowel + 2)) {$rs = 1;} // như thế này có nghĩa là 3 nguyên âm cạnh nhau
    }

return $rs;    
}



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



// kiểm tra âm đầu có hợp lệ không
// chỉ áp dụng với một từ
function vn_first_sound_check($str){
    $rs = 0; // giả định sai chính tả
    $str2 = vn_remove_accents($str); // xóa dấu
    $len = vn_num_char($str2); // lấy độ dài ký tự
    
    $first_csnt = vna_first_csnt(); // lấy mảng 26 phụ âm đầu hợp lệ
    $first_vow = vna_vowel_lett(); // các nguyên âm đơn cũng làm âm đầu được
    $diphthongs = vna_diphthongs(); // nguyên âm đôi có thể làm âm đầu
    $triphthongs = vna_triphthongs(); // nguyên âm ba có thể làm âm đầu
    
    if($len > 0) {$flett = mb_substr($str2, 0, 1, 'UTF-8');} // lấy 1 ký tự đầu tiên của từ;
    else {$flett = NULL;}  
    
    if($len > 1) {$slett = mb_substr($str2, 0, 2, 'UTF-8');} // lấy 2 ký tự đầu tiên của từ;
    else {$slett = NULL;}
    
    if($len > 2) {$tlett = mb_substr($str2, 0, 3, 'UTF-8');} // lấy 3 ký tự đầu tiên của từ;
    else {$tlett = NULL;} 
    
    if (in_array($flett, $first_vow)) {$rs = 1;} // nguyên âm đơn đầu từ thì hợp lệ
    if (in_array($slett, $diphthongs)) {$rs = 1;} // nguyên âm đôi đầu từ thì hợp lệ
    if (in_array($tlett, $triphthongs)) {$rs = 1;} // nguyên âm ba đầu từ thì hợp lệ
   
    
    // sau một phụ âm đầu hợp lệ thì phải là nguyên âm
    if (in_array($flett, $first_csnt)) { // 1 ký tự đầu tiên khớp
        
        if($len > 1) {$vow11 = mb_substr($str2, 1, 1, 'UTF-8');} else {$vow11 = NULL;} // lấy 1 ký tự sau ký tự đầu tiên
        if($len > 2) {$vow12 = mb_substr($str2, 1, 2, 'UTF-8');} else {$vow12 = NULL;} // lấy 2 ký tự sau ký tự đầu tiên
        if($len > 3) {$vow13 = mb_substr($str2, 1, 3, 'UTF-8');} else {$vow13 = NULL;} // lấy 3 ký tự sau ký tự đầu tiên
        
        if (in_array($vow11, $first_vow) || in_array($vow12, $diphthongs) || in_array($vow13, $triphthongs)) {$rs = 1;}
    }
    
    if (in_array($slett, $first_csnt)) { // 2 ký tự đầu tiên khớp
        if($len > 2) {$vow21 = mb_substr($str2, 2, 1, 'UTF-8');} else {$vow21 = NULL;} // lấy 1 ký tự sau 2 ký tự đầu tiên
        if($len > 3) {$vow22 = mb_substr($str2, 2, 2, 'UTF-8');} else {$vow22 = NULL;} // lấy 2 ký tự sau 2 ký tự đầu tiên
        if($len > 4) {$vow23 = mb_substr($str2, 2, 3, 'UTF-8');} else {$vow23 = NULL;} // lấy 3 ký tự sau 2 ký tự đầu tiên
        
        if (in_array($vow21, $first_vow) || in_array($vow22, $diphthongs) || in_array($vow23, $triphthongs)) {$rs = 1;}
    }
    
    if (in_array($tlett, $first_csnt)) { // 3 ký tự đầu tiên khớp
        if($len > 3) {$vow31 = mb_substr($str2, 3, 1, 'UTF-8');} else {$vow31 = NULL;} // lấy 1 ký tự sau 3 ký tự đầu tiên
        if($len > 4) {$vow32 = mb_substr($str2, 3, 2, 'UTF-8');} else {$vow32 = NULL;} // lấy 2 ký tự sau 3 ký tự đầu tiên
        if($len > 5) {$vow33 = mb_substr($str2, 3, 3, 'UTF-8');} else {$vow33 = NULL;} // lấy 3 ký tự sau 3 ký tự đầu tiên
        
        if (in_array($vow31, $first_vow) || in_array($vow32, $diphthongs) || in_array($vow33, $triphthongs)) {$rs = 1;}
    }

return $rs;    
}



////////////////////////////////////////////////////////////////////////////////
//////////////////////////////////////
        
        
        
// kiểm tra âm cuối từ có hợp lệ không
// chỉ áp dụng với một từ
function vn_last_sound_check($str){
    $rs = 0; // giả định sai chính tả
    $str2 = vn_remove_accents($str); // xóa dấu
    $len = vn_num_char($str2); // lấy độ dài ký tự
    
    $last_csnt = vna_last_csnt(); // lấy mảng 8 phụ âm cuối hợp lệ
    $one_vow = vna_vowel_lett(); // mảng nguyên âm đơn
    $diphthongs = vna_diphthongs(); // mảng nguyên âm đôi
    $triphthongs = vna_triphthongs(); // mảng nguyên âm ba
    
    if($len > 0) {$lflett = mb_substr($str2, -1);} // lấy 1 ký tự cuối của từ;
    else {$lflett = NULL;}  
    
    if($len > 1) {$lslett = mb_substr($str2, -2);} // lấy 2 ký tự cuối của từ;
    else {$lslett = NULL;}
    
    if($len > 2) {$ltlett = mb_substr($str2, -3);} // lấy 3 ký tự cuối của từ;
    else {$ltlett = NULL;}
    
    if (in_array($lflett, $one_vow)) {$rs = 1;} // nguyên âm đơn có thể đứng cuối
    if (in_array($lslett, $diphthongs)) {$rs = 1;} // nguyên âm đôi có thể đứng cuối
    if (in_array($ltlett, $triphthongs)) {$rs = 1;} // nguyên âm ba có thể đứng cuối
    
    
    // trước một phụ âm cuối hợp lệ thì phải là nguyên âm
    if (in_array($lflett, $last_csnt)) { // 1 ký tự cuối khớp
        if($len > 1) {$vow11 = mb_substr($str2, $len-2, 1, 'UTF-8');} else {$vow11 = NULL;} // lấy 1 ký tự trước ký tự cuối cùng
        if($len > 2) {$vow12 = mb_substr($str2, $len-3, 2, 'UTF-8');} else {$vow12 = NULL;} // lấy 2 ký tự trước ký tự cuối cùng
        if($len > 3) {$vow13 = mb_substr($str2, $len-4, 3, 'UTF-8');} else {$vow13 = NULL;} // lấy 3 ký tự trước ký tự cuối cùng
        
        if (in_array($vow11, $one_vow) || in_array($vow12, $diphthongs) || in_array($vow13, $triphthongs)) {$rs = 1;}
    }
    
    if (in_array($lslett, $last_csnt)) { // 2 ký tự cuối khớp
        if($len > 2) {$vow21 = mb_substr($str2, $len-3, 1, 'UTF-8');} else {$vow21 = NULL;} // lấy 1 ký tự trước 2 ký tự cuối cùng
        if($len > 3) {$vow22 = mb_substr($str2, $len-4, 2, 'UTF-8');} else {$vow22 = NULL;} // lấy 2 ký tự trước 2 ký tự cuối cùng
        if($len > 4) {$vow23 = mb_substr($str2, $len-5, 3, 'UTF-8');} else {$vow23 = NULL;} // lấy 3 ký tự trước 2 ký tự cuối cùng
        
        if (in_array($vow21, $one_vow) || in_array($vow22, $diphthongs) || in_array($vow23, $triphthongs)) {$rs = 1;}
    }

return $rs;    
}



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



// chỉ áp dụng với một từ
function vn_no_sound_end_check($str) { // kiểm tra các nguyên âm không được phép có âm cuối
    $rs = 1; // giả định là đúng chính tả
    $no_sound_end = vna_no_sound_end(); // lấy mảng các nguyên âm không được phép có âm cuối
    $str2 = vn_remove_accents($str); // xóa dấu, chuyển về ký tự thường
    $len = vn_num_char($str2); // lấy độ dài ký tự
    
    if($len > 2) {
        $lslett = mb_substr($str2, -2); // lấy hai ký tự cuối
        $vow12 = mb_substr($str2, $len-3, 2, 'UTF-8'); // lấy hai ký tự trước 1 ký tự cuối
    }
    else {
        $lslett = NULL;
        $vow12 = NULL;      
    }
    
    
    if($len > 3) {
        $ltlett = mb_substr($str2, -3); // lấy ba ký tự cuối
        $vow13 = mb_substr($str2, $len-4, 3, 'UTF-8'); // lấy ba ký tự trước 1 ký tự cuối        
    } 
    else {
        $ltlett = NULL;
        $vow13 = NULL;      
    }    
    
    
    // nguyên âm đôi nằm trước ký tự cuối cùng
    if (in_array($vow12, $no_sound_end)==1 && in_array($lslett, $no_sound_end)==0) {$rs = 0;}
    
    
    // nguyên âm ba nằm trước ký tự cuối cùng
    if (in_array($vow13, $no_sound_end)==1 && in_array($ltlett, $no_sound_end)==0) {$rs = 0;}
    
    
    // ngoại lệ với giáo, giáp, giác, giúp...
    $flett = mb_substr($str2, 0, 1, 'UTF-8'); // lấy ký tự đầu tiên
    if ($flett == "g" && ($vow12 == "ia" || $vow12 == "iu")) {$rs = 1;}

return $rs;    
}



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



// chỉ áp dụng với một từ, kiểm tra chính tả
function vn_simple_spell_small($str) {
    $rs = 1; // ban đầu cho là đúng chính tả
    $str2 = vn_low_rmv($str); // xóa khoảng trắng dư thừa, chuyển thành ký tự thường
    
    // không được có ký tự nước ngoài
    if (vn_foreign_check_low($str2)) {$rs = 0;}
    
    // từ tiếng Việt có nhiều chữ cái nhất là nghiêng với 7 chữ cái
    // các từ đúng chính tả chỉ có 6 chữ cái trở xuống
    if ($rs == 1) { // để nó đỡ phải thực hiện kiểm tra quá nhiều
        if (($str2 != "nghiêng") && (vn_num_char($str2)>6)) {$rs = 0;}
    }
    
    // số lượng từ có dấu không được lớn hơn 1
    if ($rs == 1) { // để nó đỡ phải thực hiện kiểm tra quá nhiều
        if(vn_num_acc_char($str2) > 1) {$rs = 0;}
    }
    
    // tối đa 3 nguyên âm, tối thiểu 1 nguyên âm và các nguyên âm cần phải đứng cạnh nhau
    if ($rs == 1) { // để nó đỡ phải thực hiện kiểm tra quá nhiều
        if (vn_vowel_next_other($str2) == 0){$rs = 0;}
    }
    
    // kiểm tra âm đầu
    if ($rs == 1) { // để nó đỡ phải thực hiện kiểm tra quá nhiều
        if (vn_first_sound_check($str2) == 0) {$rs = 0;}
    }
    
    //kiểm tra âm cuối
    if ($rs == 1) { // để nó đỡ phải thực hiện kiểm tra quá nhiều
        if (vn_last_sound_check($str2) == 0) {$rs = 0;}
    }
    
    // kiểm tra các nguyên âm không được phép có âm cuối
    if ($rs == 1) { // để nó đỡ phải thực hiện kiểm tra quá nhiều
        if (vn_no_sound_end_check($str2) == 0) {$rs = 0;}
    }
    
return $rs;    
}



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



// thiết kế kiểm tra chính tả cho một cụm nhiều từ
function vn_simple_spell_big($str) {
    $rs = 1; // gán cho đúng chính tả lúc ban đầu 
    $str2 = vn_rmv_wsp($str); // xóa bỏ khoảng trắng dư thừa
    $words = mb_split(' ', $str2); // tách từ
    
    foreach ($words as $word) {
        if ($word!=NULL){
            if (vn_simple_spell_small($word) == 0) {$rs = 0; break;}
        }    
    }

return $rs;  
}

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

Giải thích các hàm

Các hàm kiểm tra chính tả ở trên sử dụng nguyên tắc nguyên âm và phụ âm, vị trí hợp lý được phép của chúng. Về độ chính xác nó không thể bằng được soát chỉnh tả dựa trên từ điển các từ thực tế được dùng, tuy nhiên vì chưa có bộ từ điển chính xác nên tôi tạm dùng hàm trên để thao tác với một số chuỗi đơn giản trong họ tên người.

Các hàm thường xuyên sử dụng mảng nguyên âm đơn, đôi và ba. Ngoài ra là các mảng phụ âm đầu và phụ âm cuối.

Hai hàm có sẵn của PHP đáng chú ý mà tôi hay dùng trong phần này là:

  • in_array: dùng để kiểm tra một chuỗi có nằm trong mảng không;
  • mb_substr: để thực hiện cắt chuỗi, chọn ra chuỗi cần so sánh;

Giờ chúng ta sẽ thử đi giải thích hàm vn_first_sound_check($str), một trong các hàm con để kiểm tra chính tả. Nó được dùng để kiểm tra xem âm đầu từ có chính xác hay không.

Về mặt thể hiện ra văn bản tiếng Việt, hiện chúng ta có 26 phụ âm đầu hợp lệ là:

$vfc = array("b","c","ch","d","đ","g","gh","gi","h","k","kh","l","m","n","nh","ng","ngh","ph","qu","r","s","t","th","tr","v","x");

Điều này có nghĩa là các phụ âm đầu khác danh sách trên sẽ không phải từ tiếng Việt.

Ngoài phụ âm đầu thì các nguyên âm đơn, đôi, ba cũng đứng đầu được, do đó chúng ta cần lấy 3 mảng này vào để kiểm tra tất cả các âm đầu khả dĩ.

$first_csnt = vna_first_csnt(); // lấy mảng 26 phụ âm đầu hợp lệ
$first_vow = vna_vowel_lett(); // các nguyên âm đơn cũng làm âm đầu được
$diphthongs = vna_diphthongs(); // nguyên âm đôi có thể làm âm đầu
$triphthongs = vna_triphthongs(); // nguyên âm ba có thể làm âm đầu

Tiếp theo tôi viết hàm để lấy các ký tự đầu của từ cần kiểm tra, lần lượt là 1, 2, 3 ký tự. Vì âm đầu có tối đa là 3 ký tự:

if($len > 0) {$flett = mb_substr($str2, 0, 1, 'UTF-8');} // lấy 1 ký tự đầu tiên của từ;
else {$flett = NULL;}  
    
if($len > 1) {$slett = mb_substr($str2, 0, 2, 'UTF-8');} // lấy 2 ký tự đầu tiên của từ;
else {$slett = NULL;}
    
if($len > 2) {$tlett = mb_substr($str2, 0, 3, 'UTF-8');} // lấy 3 ký tự đầu tiên của từ;
else {$tlett = NULL;} 

Trước tiên, vì các nguyên âm có khả năng đứng đầu từ nên nếu các chuỗi con được tách ở trên nằm trong các mảng tương ứng thì nó được coi là không sai chính tả:

if (in_array($flett, $first_vow)) {$rs = 1;} // nguyên âm đơn đầu từ thì hợp lệ

if (in_array($slett, $diphthongs)) {$rs = 1;} // nguyên âm đôi đầu từ thì hợp lệ

if (in_array($tlett, $triphthongs)) {$rs = 1;} // nguyên âm ba đầu từ thì hợp lệ

Xử lý xong với nguyên âm, chúng ta chuyển qua phần phụ âm.

Vì bản thân phụ âm không tạo thành từ có nghĩa được, nên đằng sau nó sẽ phải có nguyên âm. Do vậy chúng ta sẽ xác thực điều đó bằng hàm sau:

 // sau một phụ âm đầu hợp lệ thì phải là nguyên âm
if (in_array($flett, $first_csnt)) { // 1 ký tự đầu tiên khớp
        
if($len > 1) {$vow11 = mb_substr($str2, 1, 1, 'UTF-8');} else {$vow11 = NULL;} // lấy 1 ký tự sau ký tự đầu tiên

if($len > 2) {$vow12 = mb_substr($str2, 1, 2, 'UTF-8');} else {$vow12 = NULL;} // lấy 2 ký tự sau ký tự đầu tiên

if($len > 3) {$vow13 = mb_substr($str2, 1, 3, 'UTF-8');} else {$vow13 = NULL;} // lấy 3 ký tự sau ký tự đầu tiên
        
if (in_array($vow11, $first_vow) || in_array($vow12, $diphthongs) || in_array($vow13, $triphthongs)) {$rs = 1;}
    }

Nếu ký tự đầu tiên nằm trong mảng phụ âm hợp lệ, tiếp theo chúng ta sẽ đi lấy lần lượt là 1, 2, 3 ký tự đằng sau ký tự đầu tiên và đối chiếu nó với các mảng nguyên âm đơn, đôi và ba.

Nếu một trong ba điều kiện thỏa mãn, nghĩa là cấu trúc của nó hợp lệ: phụ âm đầu + nguyên âm.

Tương tự như vậy nếu phát hiện phụ âm đầu là 2 chữ cái, hay 3 chữ cái. Chỉ khác là lần này chúng ta sẽ lấy 1, 2, 3 từ sau 2 hoặc 3 ký tự đầu tiên.

Hàm vn_last_sound_check($str) rất giống về mặt cấu trúc so với hàm vn_first_sound_check($str) chỉ khác là phần này liên quan đến âm cuối nên sẽ có một số cái chúng ta làm ngược lại, bạn cứ theo dõi mã, và phần comments sẽ hiểu.