【PHP】 trim not support multi byte & UTF-8 encode
問題:
在程式中處裡亞洲語言或多或少都會遇到編碼相關的問題,台灣部分最常見的就是 CJK (Chinese, Japanese, Korean) characters。在現今較常見編碼 (如 UTF-8) 上 CJK 字元都是 multi byte 的,這也導致 PHP 部分舊有 function 在處裡字串時並未考慮到 multi byte 進而導致處理結果不如預期。
PHP 為了解決這個問題提供了 mb_* 家族類 function 專門處裡這些 multi byte characters:https://www.php.net/manual/en/book.mbstring.php。但 mb_* 家族並沒有包含全部的字串處裡 function (如 trim),而這正是本次文章要講的內容,PHP trim function not support multi byte,我們先看看下面一段 PHP (ver 7.2.34) 程式碼:
|
$input = 'お鮫 ';
$mask = ' ';
$output = trim($input, $mask);
var_dump($output);
|
trim function 提供過濾字串前後空白的功能,在其他語言更常見的名稱是 strip。其中 trim 還接受第二個可選參數供用戶過濾特定的字元。
在程式碼中我嘗試利用 trim 來過濾字串前後全形空白,var_dump 結果是多少呢?
|
string(5) "��鮫"
|
看起來結果與我預期的相差甚遠,日文字整個被破壞了。接下來會解釋這個結果是怎麼運算出來的。
UTF-8:
在解答中會使用最常見的 UTF-8 編碼作為範例。所以解答前需要先講講 UTF-8 的編碼規則。UTF-8,全名 8-bit Unicode Transformation Format,如其名是一種針對 Unicode 萬國碼的轉換格式,同時此轉換格式的編碼長度是不固定的,增減長度的單位為 8-bit。
UTF-8 給予 ASCII 範圍內的基本拉丁字母、阿拉伯數字等常用字元較短的編碼 (1 byte),並給予 CJK 等較少見的字元則使用較長的編碼 (3 byte)。編碼長度可變的編碼都需要某種機制上使電腦知道接下來要解析的字元長度為多少才能被順利解析,如:
|
e3 |
81 |
8a |
|
11100011 |
10000001 |
10001010 |
一共 3 Byte,那這到底是三個常用字元,還是一個 CJK 字元呢?秘密就藏在各 byte 開頭,當
- 第一個 byte 的開頭是 0,代表這是一個 1 byte 長度的字元。
- 第一個 byte 的開頭是連續 N 個 1,代表這是一個 N byte 長度的字元。
- 同時多 byte 字元,第一個以外的 byte 都會使用 10 作為開頭。
- 剩下的 bit 就照順序組合後對應 Unicode 萬國碼的編碼。
由此可見 "111"00011 "10"000001 "10"001010 是一個 3 byte 字元,且對應 00011 000001 001010 也就是 00110000 01001010 (30 4a) 的 Unicode 萬國碼,使用國家發展委員會提供的字碼查詢結果為:
解碼完成,就是剛剛日文範例中的第一個字 "お"。
解答:
知道編碼規則後我們再來仔細看看剛剛的範例。
|
$input = 'お鮫 ';
$hex = bin2hex($input);
var_dump($input); // string(9) "お鮫 "
var_dump($hex); // string(18) "e3818ae9aeabe38080"
$mask = ' ';
$hex = bin2hex($mask);
var_dump($mask); // string(3) " "
var_dump($hex); // string(6) "e38080"
$output = trim($input, $mask);
$hex = bin2hex($output);
var_dump($output); // string(5) "��鮫"
var_dump($hex); // string(10) "818ae9aeab"
|
我們使用 bin2hex function 來印出該字串編碼的 16 位元形式,從前兩段程式可見以下這些 UTF-8 編碼:
|
お |
鮫 |
(全形空白) |
|
e3818a |
e9aeab |
e38080 |
而最後的輸出結果顯示編碼為 "818ae9aeab",這是因為 trim function 不支援 multi byte,他將全形空白的單一 multi byte 字元 "e38080" 當作要過濾 "e3"、"80"、"80" 三個字元,導致 "お" 前面的 e3 被截斷,只剩下:
|
81 |
8a |
|
10000001 |
10001010 |
但 UTF-8 的編碼規則中,字元開頭是若不是 0 就必須是多個 1,沒有單個 1 的可能在,所以導致無法解碼、亂碼等問題。
從編碼規則中我們也可以發現,trim function 只有在第二個參數給予 multi byte 的時候會觸發此問題。
- 單一 byte 的字元一定是 0 開頭、multi byte 則是 1,不可能被單一字元所匹配到自然也不會有截斷的問題。
- 不給於第二個參數時,trim function 預設值也都是單一 byte、ASCII範圍內的字元,也都沒問題。
結論上如果希望過濾 multi byte 字元,不可以使用 trim function,應採用正規表達式 (preg_replace) 與其他 mb_* 家族的方法去切割或過濾字串來達到目標。
題外話,お鮫這個詞沒有其他涵義,只是來自我喜歡的繪師而已,twitter 連結放在參考資料中。
參考資料:
[1] お鮫🦈 (@osamesamesame) / Twitter
https://twitter.com/osamesamesame
[2] PHP: Multibyte String - Manual
https://www.php.net/manual/en/book.mbstring.php
[3] PHP: trim - Manual
https://www.php.net/manual/en/function.trim.php
[4] UTF-8 - Wikipedia
https://en.wikipedia.org/wiki/UTF-8
[5] 字碼查詢 - Unicode查詢 - CNS11643 中文全字庫
https://www.cns11643.gov.tw/search.jsp?ID=7&SN=&lang=tw
[6] PHP: bin2hex - Manual
https://www.php.net/manual/en/function.bin2hex.php
[7] regex - Multibyte trim in PHP? - Stack Overflow
https://stackoverflow.com/questions/10066647/multibyte-trim-in-php
