📏

サロゲートペアだけじゃない、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() を使うので、正規化後の文字数でチェックされます。改行の多いテキストで「なんで弾かれるんだ?」ってなってたのはこれでした。

編集時の往復問題

実は新規入力だけでなく、編集画面でも同じ問題が起きます。

  1. 新規入力: ブラウザ(\n) → HTTP送信で\r\nに → DB保存(\r\n
  2. 編集画面表示: DB(\r\n) → textareaに表示 → textarea.value\nに正規化される
  3. JSカウント: \nでカウント → 「5文字」
  4. 再送信: また\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