不可視文字
画面上に表示されないが、テキストデータ内に存在する文字の総称。ゼロ幅スペース、双方向制御文字、ソフトハイフンなどが含まれ、文字数カウントに影響を与える。
不可視文字 (invisible character) は、テキスト内に存在するにもかかわらず、画面上に視覚的な表示を持たない文字の総称です。制御文字 (改行、タブなど) とは異なり、不可視文字の多くはテキストの表示方法や処理方法に影響を与える「見えない指示」として機能します。文字数カウントにおいて、不可視文字は最も厄介な落とし穴の一つです。
Unicode には多数の不可視文字が定義されています。ゼロ幅スペース (U+200B) は幅のない空白で、長い単語の途中に挿入して改行可能位置を示します。ゼロ幅接合子 (ZWJ、U+200D) は前後の文字を結合し、絵文字の合成 (👨+ZWJ+👩+ZWJ+👧 = 👨👩👧) に使われます。ゼロ幅非接合子 (ZWNJ、U+200C) は逆に結合を防ぎ、アラビア語やペルシャ語で文字の接続を制御します。
双方向制御文字も不可視文字の一種です。左から右マーク (LRM、U+200E) と右から左マーク (RLM、U+200F) は、アラビア語やヘブライ語と英語が混在するテキストで書字方向を制御します。さらに強い作用を持つのが埋め込み・上書き系 (U+202A〜U+202E) と分離子系 (U+2066〜U+2069) で、こちらは範囲を指定して並び順そのものを変えます。いずれも画面には現れませんが、テキストの表示順序に大きな影響を与えます。
不可視文字が文字数カウントに与える影響は深刻です。ウェブページからテキストをコピー&ペーストすると、ゼロ幅スペースや双方向制御文字が紛れ込むことがあります。見た目は同じテキストなのに、文字数カウンターが異なる値を返す場合、不可視文字の混入を疑うべきです。たとえば「Hello」が 5 文字ではなく 7 文字とカウントされる場合、「H」と「e」の間にゼロ幅スペースが 2 つ挿入されている可能性があります。
セキュリティの観点でも不可視文字は問題になります。2021 年には、ソースコードに双方向制御文字の上書き・分離子系を挿入し、画面上の並び順と実際に処理される順序を食い違わせる「トロイのソース」が報告されました。また、表示名やハンドル名の途中にゼロ幅文字を挟むと、見た目が同じままで文字列としては別物になります。同名かどうかの判定や禁止語の照合は文字列の一致で行うため、この差が抜け道になり得ます。
混入を疑ったときの確認手順は単純です。文字数が合わない箇所を切り出し、1 文字ずつコードポイントに展開して目で確かめます。JavaScript なら Array.from(s, c => c.codePointAt(0).toString(16))、Python なら [hex(ord(c)) for c in s] で一覧が得られます。「a」とゼロ幅スペースと「b」の 3 文字なら 61 200b 62 と並ぶので、どこに何が挟まっているかが一目で分かります。先頭に U+FEFF が付いていればファイルの BOM が原因です。
不可視文字への対策として、テキスト処理の前段階でサニタイズ (不要な不可視文字の除去) を行うことが推奨されます。正規表現 /[\u200B-\u200F\u2028-\u202F\u2060-\u206F\uFEFF]/g は主要なゼロ幅文字と双方向制御文字をまとめて拾えますが、この範囲には落とし穴が 2 つあります。1 つは、冒頭で代表例に挙げたソフトハイフン (U+00AD) がこの範囲に入っていないこと。必要なら明示的に足します。もう 1 つは、行区切り (U+2028)・段落区切り (U+2029)・狭い改行なしスペース (U+202F) が範囲に含まれることです。これらは「見えない」というより空白や改行として働く文字なので、一律に削ると改行位置や字間が変わります。
ZWJ の扱いにはとくに注意が必要です。前述の 3 人家族の絵文字を書記素単位で数えると、除去前は 1 文字ですが、ZWJ を落とすと合成が解けて 3 文字になります。除去対象は「表示に必要な制御文字」と「混入したごみ」に分けて考え、用途に応じて選択的に外すのが原則です。