サロゲートペアだけじゃない、JSの文字数カウントがPHP/MySQLとズレる罠
はじめに
JSで文字数カウントするとき、サロゲートペア(絵文字や一部の漢字などが2文字になる問題)は有名だと思います。[...str].length を使えばOK、みたいな話はよく見かけますよね。
ただ、実際にフロントエンドで文字数カウンターを実装してみたら、サロゲートペア以外にもズレる原因があって、思ったより奥が深かったのでメモしておきます。自分の理解が間違ってる部分もあるかもしれないので、参考程度にしていただければ幸いです。
何が起きたか
textarea の文字数カウンターを実装していました。サロゲートペア対策で [...str].length を使っていたのに、Laravelの max ルールで弾かれるケースがちらほらあります。
調べてみたら、改行の正規化が原因でした。
サロゲートペアとそれ以外の原因
| 原因 | JS側 | PHP/MySQL側 | ズレ |
|---|---|---|---|
| 絵文字(サロゲートペア) |
"🎉".length → 2 |
mb_strlen("🎉") → 1 |
JSが多い |
| 改行の正規化 |
"a\nb".length → 3 |
HTTP送信後 "a\r\nb" → 4 |
PHPが多い |
| 単独サロゲート | カウントされる | MySQLがエラー or 警告付きで拒否 | 保存失敗の可能性 |
| NFD形式(古いmacOS) |
"が".length → 2 |
mb_strlen("が") → 2 |
一致するけど意図せず2文字 |
サロゲートペアは [...str].length で対処できますが、改行の問題はそれだけでは解決しません。改行が多いテキストほどズレが大きくなって、例えば、10個の改行があれば+10文字ズレる計算です。
改行の正規化について
ブラウザ → サーバー間
<textarea> の中身をHTTPで送信すると、改行が \n から \r\n に正規化されます。
MDNによると、wrap="soft"(デフォルト)の場合:
the browser ensures that all line breaks in the entered value are a CR+LF pair
とあります。また、WHATWG Blogでも詳しく解説されていますが、フォーム送信時に改行がCRLFに正規化される仕様になっているようです。
// ブラウザ上
textarea.value // "あ\nい\nう" → 5文字
// PHPで受信
$_POST['field'] // "あ\r\nい\r\nう" → 7文字
Laravelの max ルールは mb_strlen() を使うので、正規化後の文字数でチェックされます。改行の多いテキストで「なんで弾かれるんだ?」ってなってたのはこれでした。
編集時の往復問題
実は新規入力だけでなく、編集画面でも同じ問題が起きます。
-
新規入力: ブラウザ(
\n) → HTTP送信で\r\nに → DB保存(\r\n) -
編集画面表示: DB(
\r\n) → textareaに表示 →textarea.valueは\nに正規化される -
JSカウント:
\nでカウント → 「5文字」 -
再送信: また
\r\nに正規化 → PHP/MySQL → 「7文字」
textareaの .value プロパティは「API value」と呼ばれていて、取得時に\n(LF)に正規化されるようです。つまりDBに\r\nで保存されていても、JSで取得すると\nになってしまいます。
この往復でJSとサーバー側のカウントが常にズレるので、やっぱりJS側で\r\nに正規化してからカウントする必要があります。
DB側の保存
MySQL、PostgreSQL、SQL Serverなどの主要なDBは、\r\n(CRLF)をそのまま2バイトのデータとして保存するようです。
- Windows環境で作られたテキストを保存すれば
\r\nになる - Linux/Mac環境(または最近のWebブラウザ)からの入力なら
\n(LF)になる
つまりDBは改行コードを勝手に変換したりしないので、入力元の環境次第でデータが変わってしまいます。
DB特有の注意点
MySQL / MariaDB
基本的にそのまま保存されます。ただ、コマンドラインから LOAD DATA INFILE などでCSVを取り込む際、デフォルトの改行コード設定がズレていると、データの末尾に \r がゴミとして残ってしまうことがあるみたいです。
SQL Server
Windowsとの親和性が高いので \r\n が標準的に扱われるようです。SSMS(管理ツール)で結果をコピー&ペーストすると、勝手に改行コードが変わることがあるので注意が必要かもしれません。
その他の原因
単独サロゲート
コピペとかで壊れたデータが混入することがあるみたいです。MySQLでは不正なUTF-8としてエラーになる、または警告付きで切り捨てられる可能性があります。
sql_mode の設定によって挙動が変わり、STRICTモードならエラーで停止、そうでなければ警告を出して切り捨てや置換が行われるようです。
NFD形式
古いmacOS(HFS+)のファイル名で使われていた分解形式です。例えば、見た目は同じ「が」でも内部表現が違います。
"が".length // → 1(NFC: 合成形式)
"が".normalize("NFD").length // → 2(NFD: か + 濁点)
Apple公式ドキュメントによると、macOS High Sierra(2017年)以降のAPFSでは、HFS+と違って正規化形式をそのまま保存するようになったみたいです。ただ、macOSレイヤーで正規化が行われる場合もあるようで、完全にNFD問題がなくなったわけではないかもしれません。
実際のところ、textarea入力や通常のWeb入力でNFDが来るケースはかなり稀で、主にファイル名経由やコピペ時に問題になることが多いようです。
MySQLはデフォルトで正規化しないので、入力されたままの形式で保存されます。MySQLの照合順序(Collation)によっては、NFDとNFCを「同じもの」として検索・比較できるようですが、保存されるバイト数や CHAR_LENGTH の結果は異なるので、文字数カウントの観点では一応注意が必要です。
JSの文字数カウント方法の比較
str.length 、 [...str].length の使用と、他の方法の違いも整理しておきます。
基本の比較
| 方法 | 単位 | "🎉" |
"👨👩👧" |
"\r\n" |
|---|---|---|---|---|
str.length |
UTF-16コードユニット | 2 | 8 | 2 |
[...str].length |
コードポイント | 1 | 5 | 2 |
Intl.Segmenter |
書記素クラスタ | 1 | 1 | 1 |
TextEncoder |
バイト数 | 4 | 18 | 2 |
MySQL CHAR_LENGTH()
|
コードポイント | 1 | 5 | 2 |
MySQLの CHAR_LENGTH() は \r\n を2文字と数えるので、Intl.Segmenter(1文字)を使うとズレが生じます。
Intl.Segmenter vs スプレッド構文
もうちょっと詳しく比較するとこんな感じになりました。
| 文字の種類 | Intl.Segmenter |
スプレッド構文 [...]
|
|---|---|---|
Windows改行 (\r\n) |
1文字 | 2文字に分かれる |
| 異体字 (葛󠄀, 邊󠄒など) | 1文字 | 2文字に分かれる |
| 私用領域の外字 (U+E000など) | 1文字 | 1文字 |
| 連結絵文字 (👩👩👧) | 1文字 | バラバラになる |
Intl.Segmenter の罠
Intl.Segmenter は「人間が見た目で認識する文字数」を返してくれます。
const segmenter = new Intl.Segmenter("ja", { granularity: "grapheme" });
[...segmenter.segment("👨👩👧")].length // → 1(見た目通り)
[...segmenter.segment("\r\n")].length // → 1(CRLFで1文字)
一見良さそうなんですが、MySQLのVARCHAR制限には使えないようです。👨👩👧 は Intl.Segmenter では1文字ですが、MySQLの CHAR_LENGTH() では5文字としてカウントされるので、Segmenterで制限内でもDBでオーバーしてしまいます。
ちなみにX(旧Twitter)は独自の方式を使っているようです。Emojipediaによると、2018年に全ての絵文字を一律2文字としてカウントするように変更されたとのこと。Intl.Segmenter とも [...str].length とも違う方式ですね。
私用領域(PUA)について
Windowsで一般的に「外字」として登録される領域(Private Use Area: E000-F8FF)については、Intl.Segmenter もスプレッド構文も問題なく1文字として扱えるようです。
ただし、あくまで「1つのコードポイント」として認識するだけで、Intl.Segmenter はその文字が「どんな見た目か」までは関知しないみたいです。
単独サロゲートの処理
単独サロゲートの扱いも方法によって違うようです。
| 方法 | 単独サロゲート | 結合文字 |
|---|---|---|
[...str] |
残る | 分解される |
str.match(/./gu) |
除外される | 分解される |
Intl.Segmenter |
残る | 1文字として維持 |
TextEncoder
UTF-8のバイト数を返します。MySQLの LENGTH() やインデックスのバイト制限(3072バイトとか)のチェックに使えそうです。
new TextEncoder().encode("あいう").length // → 9(3バイト×3文字)
用途別
| 用途 | 方法 |
|---|---|
| MySQL VARCHAR(N) 制限 |
[...str].length(※単独サロゲート等を除けば) |
| MySQLインデックスのバイト制限 | TextEncoder().encode(str).length |
| 見た目の文字数 | Intl.Segmenter |
やりがちなミス
サロゲートペア対策だけで満足する
// サロゲートペアは対策できてるけど、改行でズレる
const count = [...str].length;
str.length をそのまま使う
// 絵文字が2文字扱いになる
"🎉".length // → 2
インデックスでループする
// サロゲートペアが分割される
for (let i = 0; i < str.length; i++) {
const char = str[i]; // 🎉 が壊れる
}
// for...of ならコードポイント単位
for (const char of str) {
console.log(char); // 🎉 がそのまま
}
Intl.Segmenter をVARCHAR制限に使う
上で書いた通り、MySQLのカウントと合わないです。
PHPで strlen() を使う
// バイト数になる
strlen("あいう") // → 9
// こっち
mb_strlen("あいう", 'UTF-8') // → 3
対策例
MySQLの VARCHAR(N) 制限と同期させるために、こんな感じの関数を使うと良さうです。ただ、もっと良いやり方があるかもしれません。
export function countForMySQL(str: string, charLimit: number) {
// 1. 単独サロゲートを除外してカウント(MySQLでは不正なUTF-8として扱われるため)
const validChars = str.match(/[\uD800-\uDBFF][\uDC00-\uDFFF]|[^\uD800-\uDFFF]/gu) ?? [];
// 2. 改行をCRLFに正規化してカウント(HTTP送信後の状態に合わせる)
const normalized = validChars.join('').replace(/\r?\n/g, "\r\n");
// 3. コードポイント数をカウント(= MySQL CHAR_LENGTH())
const charLength = [...normalized].length;
// 4. バイト数(インデックス制限用)
const byteLength = new TextEncoder().encode(normalized).length;
return {
isValid: charLength <= charLimit,
charLength,
byteLength,
};
}
ポイントは、元のデータを書き換えずに「MySQLにどうカウントされるか」だけを返すようにしているところです。単独サロゲートはMySQLで不正なUTF-8として扱われるのでカウントからは除外していますが、入力データ自体は加工しません。
単独サロゲートが含まれている場合にバリデーションで弾くかどうかは、呼び出し側で判断すればいいかなと思います。
対応表
| 概念 | MySQL | PHP | JavaScript |
|---|---|---|---|
| 文字数(コードポイント) | CHAR_LENGTH(col) |
mb_strlen($str, 'UTF-8') |
[...str].length |
| バイト数 | LENGTH(col) |
strlen($str) |
new TextEncoder().encode(str).length |
| 見た目の文字数 | - | grapheme_strlen($str) |
Intl.Segmenter |
まとめ
サロゲートペア対策で [...str].length を使う、というのはよく知られていますが、それだけでは不十分なケースがあります。
Intl.Segmenter は見た目の文字数としては正しいんですが、DBの制限チェックには使えないのがちょっとややこしいですよね。
NFD正規化が必要なケースもあるかもしれませんが、かなりレアケースだと思われるので一旦は無視しても良のかもとは思っています。
Discussion