不可視Unicode文字は、表示上は幅を持たないものの、文字列の一部として存在します。代表的なものはゼロ幅スペース、ソフトハイフン、バイトオーダーマーク、ノーブレークスペース、方向制御文字です。どれも組版上の正当な役割がありますが、コピー&ペーストを経て残存すると、検索、入力チェック、パース、重複排除を壊します。
実際に遭遇する文字の一覧
| 文字 | コードポイント | 役割 | 壊れるもの |
|---|---|---|---|
| ゼロ幅スペース | U+200B | 改行位置の候補を示す | 検索、文字列比較 |
| ゼロ幅ノンジョイナー | U+200C | 文字の結合を防ぐ | 合字、アラビア文字・インド系文字 |
| ゼロ幅ジョイナー | U+200D | 文字の結合を強制する | 絵文字シーケンス、文字数 |
| ソフトハイフン | U+00AD | 折り返し時のみ表示されるハイフン | 検索、コピーした文章 |
| ノーブレークスペース | U+00A0 | 折り返さないスペース | トリム、CSVのパース、コード |
| 細いノーブレークスペース | U+202F | 句読点前の細いスペース | 上記と同様 |
| バイトオーダーマーク | U+FEFF | ファイル先頭でエンコーディングを示す | CSVの先頭列、JSONのパース |
| ワードジョイナー | U+2060 | 幅を持たずに改行を防ぐ | 検索 |
| 左から右へのマーク | U+200E | 文字方向を指定する | 表示順、トリム |
| 右から左へのマーク | U+200F | 文字方向を指定する | 表示順 |
| 右から左へのオーバーライド | U+202E | 右から左の表示を強制する | ファイル名の偽装 |
| 行区切り文字 | U+2028 | Unicodeの改行 | JavaScriptの文字列リテラル |
| 段落区切り文字 | U+2029 | Unicodeの段落区切り | 古いパーサーでのJSON |
これらはすべて、本来の役割を持つ文字です。問題はその存在自体ではなく、扱い方の異なるシステム間を音もなく移動してしまうことにあります。
どこから混入するのか
- Webページからのコピー。 サイト側が長い文字列の折り返し位置を制御するためにゼロ幅スペースを、両端揃えのためにソフトハイフンを挿入しています。コピーすると両方が一緒についてきます。
- ワープロソフト。 数値と単位を分離しないため、名前を1行に収めるためなどに、ノーブレークスペースが自動で挿入されます。
- PDFからの抽出。 PDFからコピーすると、組版時に単語が行をまたいで分割された位置にソフトハイフンが混入しやすく、両端揃えのスペースからノーブレークスペースも混入します。
- AIチャットのインターフェース。 出力はHTMLとしてレンダリングされるため、コピーにはその書式で使われたスペース文字(多くの場合U+00A0とU+202F)が含まれます。
- エンコーディング変換。 ファイルがBOM付きUTF-8で保存されるとバイトオーダーマークが付きます。Windowsでよく見られます。
- 意図的な挿入。 ゼロ幅文字は、リーク元を追跡するための文書へのフィンガープリント埋め込みや、キーワードフィルターの回避にも使われてきました。
一貫したパターンがあります。文章が表示の文脈からデータの文脈へ移るときに混入するのです。
どうやって問題を引き起こすのか
検索が静かに失敗する。 単語の中にゼロ幅スペースが入っていると、その単語を検索してもヒットしません。理由を示すものは何もなく、単語は目の前に見えているのに、です。
正しい入力がバリデーションで弾かれる。 末尾にノーブレークスペースが付いたメールアドレスは形式チェックに通りません。ユーザーからは正しいアドレスと、意味不明なエラーメッセージだけが見えます。
比較が失敗する。 "Ada" === "Ada\u200B" はfalseです。重複排除は明らかな重複を見逃し、検索は何も返さず、JOINは行を落とします。
トリムしても解決しない。 多くのtrim()実装はASCIIの空白文字しか除去しません。ノーブレークスペースは多くの言語で.trim()をすり抜けます。「もうトリムしたのに」が典型的な行き止まりである所以です。
コードがコンパイルできない。 通常のスペースがあるべき場所にノーブレークスペースがあると、見た目は完璧な行で構文エラーが出ます。
CSVのインポートが壊れる。 バイトオーダーマークが先頭のヘッダーに付着し、idが\uFEFFidになって、最初の列が静かにマッピングに失敗します。
文字数がずれる。 絵文字シーケンス内のゼロ幅ジョイナーのせいで、見た目1つの絵文字が複数のコードポイントになることがあり、余裕で収まっているはずの入力が文字数制限で弾かれます。
ファイル名が人を欺く。 U+202Eは表示文字列を反転させるため、report\u202Egnp.exeがreportexe.pngのように表示され得ます。これは珍奇な話ではなく、実際のセキュリティ上の懸念です。
検出と削除の方法
まずスキャンする。 不可視文字を削除ツールに文章を貼り付けてください。どの種類の文字がいくつ含まれているかをレポートし、必要に応じて削除します。件数を先に見れば、混入が1文字だけのものか、系統的な汚染かが分かります。
エディターで確認する。 VS Codeはunicode-highlight設定により、ほとんどの不可視文字をデフォルトでハイライトします。正規表現対応の検索・置換では、次のパターンで代表的な文字をまとめて捕捉できます。
[\u200B-\u200F\u00AD\uFEFF\u2060\u202A-\u202E\u2028\u2029]
コードでは、データが到着した境界で正規化します。使うときではなく、受け取ったときに。
const clean = input
.replace(/[\u200B-\u200F\u2060\uFEFF\u00AD\u202A-\u202E]/g, "")
.replace(/[\u00A0\u202F]/g, " ")
.normalize("NFC")
.trim();
2段階の処理になっている点に注目してください。ゼロ幅文字は削除しますが、ノーブレークスペースは通常のスペースに変換します。削除してしまうと単語がくっついてしまうからです。
闇雲に除去しない。 ゼロ幅ジョイナーは絵文字シーケンスや、アラビア語、ペルシア語、多くのインド系文字の表記において構造的に重要です。除去すると単語の表示が変わってしまいます。識別子、キー、コードでは積極的に除去し、ユーザーに見せる文章では慎重に除去してください。
AIツールがこれらの文字を意図的に挿入するのかという疑問については、ChatGPTは不可視文字を隠しているのかをご覧ください。目に見えるものの同様に厄介な問題については、曲線引用符がコードを壊す理由で解説しています。
よくある質問
ゼロ幅スペースとは何ですか?
U+200Bのことで、表示上の幅を持たないものの文字列の一部として存在する文字です。Webサイトでは長い単語の折り返し位置を示すために使われます。見た目の文章は同じでも内部のバイト列が異なるため、検索や文字列比較を壊します。
明らかに存在する文章が検索でヒットしないのはなぜですか?
ほぼ間違いなく、単語の中に不可視文字が入っています。多くはWebページやPDFから持ち込まれたゼロ幅スペースかソフトハイフンです。表示された文章は入力したものと一致していますが、内部の文字列は一致していません。
trim()でノーブレークスペースは除去できますか?
通常はできません。多くのtrim実装はASCIIの空白文字しか除去しないため、ノーブレークスペース(U+00A0)は残ります。前後にスペースがないように見える入力が、トリム後もバリデーションに失敗するのはこのためです。
文章から不可視文字を削除するにはどうすればよいですか?
一覧表示と削除ができるスキャナーに貼り付けるか、U+200B〜U+200F、U+00AD、U+FEFF、U+2060、U+202A〜U+202Eをカバーする正規表現を使います。ゼロ幅文字は削除してよいですが、ノーブレークスペースは削除せず通常のスペースに変換してください。でないと単語がくっついてしまいます。
不可視文字が役に立つ場面はありますか?
あります。ソフトハイフンは両端揃えの文章で単語の分割位置を制御し、ノーブレークスペースは数値と単位を分離させず、ゼロ幅ジョイナーは絵文字シーケンスやアラビア語、ペルシア語、インド系文字の表記に不可欠です。コードや識別子では積極的に、文章では慎重に除去してください。