12k
All articles

アプリに貼り付けられたテキストをクリーンアップする

Webフォームに貼り付けたテキストを、Unicode正規化、書式文字の削除、空白の圧縮、ZWJとNBSPの安全な扱いで整えます。

OpenReplay Team
OpenReplay Team
アプリに貼り付けられたテキストをクリーンアップする

Web フォームに貼り付けられたテキストには、誰も入力しておらず、どのフォントも描画しない不可視文字が日常的に紛れ込んでいます。ゼロ幅スペース、ソフトハイフン、バイトオーダーマーク、ノーブレークスペースなどがそれで、これらはそのままデータベースに保存され、完全一致比較、長さのバリデーション、検索を静かに壊していきます。

このバグを追いかけた経験があるなら、展開は見当がつくはずです。誰かがワープロソフトから職務経歴の一段落を求人応募フィールドに貼り付ける。画面上ではまったく普通に見えるのに、バリデータが拒否する。あるいは問題なく保存されたのに、その後誰もそのレコードを見つけられなくなる。インデックス内の名前に、検索ボックスからはどうやっても入力できない文字が含まれているからです。

この記事では、実際にフィールドに入り込むものが何かを示し、コードポイントを1つずつ潰していくアプローチが決して収束しない理由を説明し、削除してはいけない不可視文字も含めてこのクラス全体を扱う4ステップのクリーニング関数を提示します。

要点

  • 貼り付けテキストとともに紛れ込む不可視文字は、Unicode の一般カテゴリ Format に属します。JavaScript の正規表現では \p{Cf} と書き、このカテゴリマッチ1つで、際限なく増え続ける個別コードポイントのリストを置き換えられます。
  • normalize("NFC") が直すのは「綴り」であって「不可視性」ではありません。同じアクセント付き文字の2つのエンコーディングを等価に比較できるようにしますが、ゼロ幅スペースはそのままの位置に残します。
  • U+00A0 のノーブレークスペースは space 文字であって format 文字ではないため、\p{Cf} によるストリップでは一切除去されず、空白の畳み込みステップで処理する必要があります。
  • U+200D ZERO WIDTH JOINER は実際に仕事をしています。複数人からなる絵文字の各パーツを1つのグリフに結合し、またアラビア文字やインド系文字では接合子が正書法上の意味を持ちます。
  • \p{...} が Unicode プロパティを意味するのは、正規表現に u または v フラグが付いている場合だけです。どちらもなければ、これは単にリテラルの文字 p を表す identity escape になります。

実際にフィールドに届く不可視文字とは何か

ワープロソフトやリッチテキストエディタから貼り付けられた文字列には、目に見える組版上の置換文字と、目に見えない format 文字が混在しているのが通例です。曲線引用符、en ダッシュと em ダッシュ、省略記号文字は目に見えますし、ほとんど無害です。一方、ノーブレークスペース、ソフトハイフン、ゼロ幅スペース、バイトオーダーマークはまったく見えず、これらが等価性チェックを壊します。

文字列をコードポイントにダンプすれば、それだけで説明は済みます。

function inspect(str) {
  return [...str]
    .map((ch) => ch.codePointAt(0))
    .filter((cp) => cp > 0x7f)
    .map((cp) => "U+" + cp.toString(16).toUpperCase().padStart(4, "0"));
}

const pasted = "\uFEFFSenior\u00A0Engineer\u200B, 2019\u20132024";

console.log(inspect(pasted)); // [ 'U+FEFF', 'U+00A0', 'U+200B', 'U+2013' ]
console.log(pasted.length);   // 28, for 26 characters a human would count

split("") ではなくスプレッド構文を使ってください。文字列イテレータはコードポイント単位で値を返しますが、split("") は UTF-16 の境界で追加面の文字を真っ二つに切断します。

手元にある文字列に実際に何が入っているかを確認するには、invisible character cleaner に貼り付けてコードポイントを読み取ってください。

この種の不具合は「見えないこと」によって定義されます。フィールドは正しくレンダリングされるため、バグのスクリーンショットには何もおかしなところが写らず、報告者も自分が何を違うやり方でしたのか説明できません。セッションリプレイは、これを表面化させられる数少ない手段の1つです。放棄されたフォームのリプレイには、貼り付けとその後のリトライループが映るからです。フィールドを消して、たった今拒否されたものと見た目がまったく同じ値を打ち直している様子が見えます。

ゼロ幅文字のブロックリストはなぜ増え続けるのか

特定のコードポイントを列挙した文字クラスは、書き方が悪いのではなく、修正の「形」自体が間違っています。すでに誰かがバグ報告した文字しか含められないからです。最初の報告で U+200B を削除し、CSV インポートが壊れたら U+FEFF を追加し、ハイフン付きの姓がマッチしなくなったら U+00AD を追加する。集合に名前を付ける代わりに集合の要素を列挙しているので、このクラスは増え続けます。

その集合には名前があります。ゼロ幅スペース (U+200B)、ゼロ幅非接合子 (U+200C)、ゼロ幅接合子 (U+200D)、ソフトハイフン (U+00AD)、バイトオーダーマーク (U+FEFF) はいずれも Unicode Character Database に従って General_Category=Cf を持ち、この割り当ては標準の多くのバージョンにわたって安定しています。U+00A0 のノーブレークスペースはこの集合に含まれません。これは Zs、つまり space separator であり、だからこそカテゴリによるストリップだけでは、最も一般的なワープロ由来のアーティファクトが残ってしまうのです。

Normalize、Strip、Collapse、Trim

修正は、順序の決まった4ステップを1パスで行うものです。エンコーディングを正規化し、format 文字を除去し、あらゆる空白のバリエーションを通常のスペースに畳み込み、最後にトリムします。

const FORMAT_CHARS = /[\p{Cf}--[\u200C\u200D]]/gv;
const SPACE_RUN = /[\p{Zs}\t\n\r]+/gu;

export function cleanPastedText(input) {
  return input
    .normalize("NFC")               // one canonical spelling per character
    .replace(FORMAT_CHARS, "")      // BOM, zero-width space, soft hyphen, bidi controls
    .replace(SPACE_RUN, " ")        // NBSP, thin spaces, tabs, newlines -> one space
    .trim();
}

改行が内容の一部となる textarea のフィールドであれば、SPACE_RUN から \n\r を外してください。

ここでは2つの点が重要です。\p{...} が Unicode 上の意味を持つのは、正規表現が Unicode 対応モードにある場合だけです。u と v の両方を外すと、エンジンは \p をエスケープされたリテラルの p として読み取るため、パターンはコンパイルも実行もされますが、意図したものには何もマッチしません。もう1つ、畳み込みのステップでは \s ではなく \p{Zs} をベースにした明示的なクラスを使っています。こうすることで、U+00A0 がカバーされていることが呼び出し箇所から明らかになります。

順序も重要です。normalize("NFC") が解決するのはエンコーディングであって不可視性ではないため、ゼロ幅スペースはそのまま残し、ストリップのステップに処理を委ねます。畳み込みの前にストリップすることで、バイトオーダーマークは余計なスペースに変換されるのではなく削除されます。そしてトリムを最後に行うことで、除去された format 文字の隣にあったスペースが先頭に残るケースを拾えます。

削除してはいけないもの

すべての format 文字を無差別に削除すると、実際のコンテンツが壊れます。U+200D ZERO WIDTH JOINER は絵文字を1つのグリフに結合する文字です。Unicode 絵文字標準では、そもそも文字列を emoji ZWJ sequence たらしめているのは接合子の存在ですから、接合子を取り除けば、1つだったグリフが複数に分かれてしまいます。

const family = "👨‍👩‍👧";

console.log([...family].length);                          // 5
console.log([...family.replace(/\p{Cf}/gu, "")].length);  // 3 -> 👨👩👧

接合子は絵文字以外でも装飾ではありません。アラビア文字の書き手は、本来なら筆記体としてつながってしまう2文字の間に非接合子を置いて連結を止めますし、Unicode コア仕様は、これらの制御文字を取り除いたテキストは別の意味になるか、意味をなさなくなると警告しています。デーヴァナーガリーでは、ヴィラーマの後の ZWJ が、完全な合字ではなく子音の半形を選択します。原則は、対象のフィールドで意味を持たない不可視文字は削除し、構造的な役割を果たしているものは残す、ということです。

[\p{Cf}--[\u200C\u200D]] はまさにそれを表現しています。v フラグは文字クラスに集合演算子を追加し、-- はその中の差集合演算子です。unicodeSets が使えないランタイムでは、u モードでの等価な記述は /(?![\u200C\u200D])\p{Cf}/gu になります。1つの正規表現に両方のフラグを設定しないでください。これらは排他的です。

なぜ NFKC ではなく NFC を使うべきなのか

NFC は、Unicode が同じ文字を綴る2通りの方法を解決し、それ以外は何も変えません。NFKC はさらに踏み込んで互換文字を書き換えます。normalize() の例が示すように、ff リガチャは2つの f になり、丸囲みの Ⓓ は単なる D になります。これはコンテンツを変更する決定であり、Unicode 正規化附属書では正準等価ではなく互換等価として定義されています。フォームフィールドのクリーニングの副作用として引き受けるのではなく、意図的に判断すべき事柄です。

関連する落とし穴を1つ。ソフトハイフンの削除はあなたのクリーナーの仕事であって、normalize("NFKC") の仕事ではありません。U+00AD を空文字列にマッピングする Unicode の操作は NFKC_Casefold であり、String.prototype.normalize("NFKC") が行う変換とは別物です。

クリーナーはサーバー側でも実行する

ブラウザでのクリーニングは入力する人への配慮に過ぎません。データベースと検索インデックスが依拠する正規化は、サーバー側で実行されなければなりません。リクエストはあなたのページを一度も読み込むことなく送信できるからです。公開 API、CSV インポート、Webhook、モバイルクライアントを通って届くものはすべて入力ハンドラを完全に迂回しますし、クリーニングされていない行が1つあるだけで、一意制約や完全一致検索の挙動は一貫性を欠いたものになります。同じ関数が Node でもブラウザでも動作するので、データが永続化層に入る境界で実行し、クライアント側の呼び出しは保証ではなく即座のフィードバックとして位置づけてください。

文字クラスにコードポイントを追加するのはやめて、カテゴリに名前を付けましょう。すべての入口に適用されるこの4行が、フィールドで意味を持たない不可視文字を除去しつつ、実際のテキストをまとめている文字は残します。結合アクセント、ノーブレークスペース、ソフトハイフン、バイトオーダーマーク、ゼロ幅スペース、ZWJ 絵文字を混ぜたフィクスチャを書き、クリーナーが1つ目を合成し、2つ目を通常のスペースに畳み込み、次の3つを削除し、絵文字を変更せずに返すことをアサートしてください。

FAQ

trim はノーブレークスペースやゼロ幅スペースを除去しますか?

trim は文字列の両端からのみ空白文字と行終端子を除去します。ECMAScript の空白リストにはノーブレークスペース U+00A0 とバイトオーダーマーク U+FEFF が含まれるため、どちらも端にあれば消えます。ゼロ幅スペース U+200B は空白ではなく format 文字なので決して除去されませんし、文字列の途中にあるこれらの文字にはいずれも一切触れません。

format 文字のストリップは U+FE0F のような絵文字の異体字セレクタも除去しますか?

いいえ。異体字セレクタ U+FE00 から U+FE0F は General_Category が Cf ではなく Mn(nonspacing mark)なので、format カテゴリのストリップでは残り、絵文字は意図された表示を保ちます。keep-list が必要なのは接合子だけです。クリーナーを広げて mark も削除するようにすると、異体字セレクタとすべての結合アクセントまで一緒に削除してしまいます。

format 文字のストリップは right-to-left override 文字も除去しますか?

はい。双方向制御文字、すなわち U+200E、U+200F、U+202A から U+202E の埋め込みとオーバーライド、U+2066 から U+2069 の分離子はいずれも General_Category が Cf なので、1つのカテゴリマッチでゼロ幅スペースとまとめて除去されます。これは表示名やファイル名で重要です。right-to-left override は描画されるテキストを反転させ、拡張子を偽装できるからです。

貼り付けた値が十分短く見えるのに maxlength で弾かれるのはなぜですか?

maxlength は可視文字数ではなく UTF-16 コードユニット数を数えるため、見えない同乗者がすべて予算を消費します。バイトオーダーマークやゼロ幅スペースは1ユニット、サロゲートペアで構成される絵文字は2ユニットを消費します。長さを検証する前に値をクリーニングし、上限を人が見る文字数に合わせたいのであればスプレッド構文で数えてください。

Open-source session replay

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.