X(Twitter) にツイートするとき、@ を含む文がメンションリンクになったり、URLが勝手にリンク化されたりして困ることがある。これを防ぐ方法として、見えない「ゼロ幅文字」を間に挟む手法がある。リンク化回避ツールとしても公開しているが、そもそもゼロ幅文字とは何なのか、なぜ見えないのにリンクを壊せるのか、改めて仕組みを調べてみた。

ゼロ幅文字の種類

Unicode にはゼロ幅の文字がいくつかある。よく遭遇するのは次の 4 つ。

名称 コードポイント 略称 役割
Zero-Width Space U+200B ZWS 改行可能な位置を示す。表示幅ゼロのスペース
Zero-Width Non-Joiner U+200C ZWNJ 文字の結合を抑制する。ペルシャ語・アラビア語で使われる
Zero-Width Joiner U+200D ZWJ 文字を結合させる。絵文字の合成(家族絵文字など)に使われる
BOM / Zero-Width No-Break Space U+FEFF BOM ファイル先頭でエンコーディングを示す。本文中に残ると不可視文字になる

この中で日常的に問題を起こしやすいのが ZWS(U+200B)と BOM(U+FEFF)だ。ZWS は Web ページや SNS で頻繁に使われており、コピペで意図せず持ち込まれることが多い。BOM はテキストファイルの先頭に残っていて、スクリプトの読み込みや CSV のパースで邪魔になることがある。

ZWJ はふだん見かけない文字に思えるが、実は毎日のように使っている。スマートフォンの絵文字で「家族」や「カップル」「肌の色違い」の絵文字を出すとき、内部では複数の絵文字を ZWJ でつないで 1 つの絵文字として表示している。たとえば「👩‍👩‍👧‍👦」は、女性+ZWJ+女性+ZWJ+女の子+ZWJ+男の子の 7 文字で構成されている。

どこから混入するか

ゼロ幅文字がテキストに紛れ込む経路はいくつかある。

  • Web ページからのコピペ: CMS や WYSIWYG エディタが行間や単語区切りに ZWS を挿入していることがある。見た目は普通のテキストだが、コピーすると一緒について来る
  • SNS・チャットアプリ: 一部のアプリは文字列の整形時にゼロ幅文字を挿入する。Slack や Discord でコピーしたテキストに混入するケースが報告されている
  • テキストファイルの BOM: Windows のメモ帳で UTF-8 保存すると、ファイル先頭に BOM(U+FEFF)が付く。別の環境で開いたとき、この 3 バイトがゴミ文字として表示されたりパースエラーの原因になったりする
  • PDF からのコピー: PDF の内部構造によっては、行末のハイフネーションや段組みの折り返しにゼロ幅文字が埋め込まれていることがある
  • 意図的な埋め込み: テキストの出所を追跡する「フィンガープリンティング」に使われることもある。文書の特定箇所にゼロ幅文字を仕込んで、流出元を特定する手法

混入するとどうなるか

見えない文字が入っているだけなので表示は壊れない。問題になるのは文字列を「データ」として扱うときだ。

  • 文字列比較の失敗: 見た目は同じ "hello" でも、片方にゼロ幅文字が入っていれば ===false になる。デバッグで目視しても違いが見えない
  • 検索のすり抜け: テキスト中に ZWS が入っていると、その語を検索してもヒットしないことがある。「ユーザー名」と「ユー​ザー名」(ZWS入り)は別の文字列
  • 正規表現のミスマッチ: /^[a-z]+$/ のようなパターンに ZWS 入りの文字列を通すと、見た目は英小文字だけなのにマッチしない
  • CSV・JSON のパースエラー: BOM がファイル先頭にあると、最初のキー名に BOM がくっついて認識されない。"name" ではなく "name" になる
  • 文字数カウントのずれ: 入力バリデーションで「10文字以内」としているのに、ゼロ幅文字の分だけ実際のバイト数が増える

検出する方法

JavaScript で検出する

文字列を 1 文字ずつ走査して、ゼロ幅文字のコードポイントに一致するものを探す。

function findZeroWidth(text) {
  const zwChars = {
    '\u200B': 'Zero-Width Space (ZWS)',
    '\u200C': 'Zero-Width Non-Joiner (ZWNJ)',
    '\u200D': 'Zero-Width Joiner (ZWJ)',
    '\uFEFF': 'BOM / Zero-Width No-Break Space',
  };

  const found = [];
  for (let i = 0; i < text.length; i++) {
    const char = text[i];
    if (zwChars[char]) {
      found.push({ position: i, name: zwChars[char], codePoint: char.codePointAt(0).toString(16) });
    }
  }
  return found;
}

手っ取り早く「ゼロ幅文字が混じっているかどうか」だけ知りたければ、正規表現でテストするだけでよい。

/[​-‍]/.test(text)

テキストエディタで確認する

  • VS Code: 設定で editor.renderWhitespace"all" にすると空白系の文字が可視化される。さらに editor.unicodeHighlight.ambiguousCharacters を有効にすると、見慣れない Unicode 文字がハイライトされる
  • 正規表現検索: 多くのエディタで Ctrl+H(置換)を開き、正規表現モードで [​-‍] を検索すれば該当箇所が見つかる

ターミナルで確認する

# hexdump でバイト列を確認(e2 80 8b が ZWS)
hexdump -C file.txt | grep 'e2 80 8b'

# cat -v で非表示文字を可視化
cat -v file.txt

除去する方法

JavaScript で除去する

// ゼロ幅文字をまとめて除去
function removeZeroWidth(text) {
  return text.replace(/[\u200B-\u200D\uFEFF]/g, '');
}

// 使用例
const dirty = 'hello\u200Bworld';
console.log(dirty.length);                 // 11(見た目は10文字)
console.log(removeZeroWidth(dirty).length); // 10

ターミナルで除去する

# macOS (BSD sed) — ゼロ幅スペース (U+200B) を除去
sed $'s/\xe2\x80\x8b//g' input.txt > output.txt

# GNU sed(Linux)
sed 's/\xe2\x80\x8b//g' input.txt > output.txt

ZWS(U+200B)の UTF-8 バイト列は e2 80 8b。ZWNJ は e2 80 8c、ZWJ は e2 80 8d。まとめて除去したい場合は sed を複数回パイプするか、perl で書くとまとめやすい。

# perl でゼロ幅文字を一括除去
perl -CSD -pe 's/[\x{200B}-\x{200D}\x{FEFF}]//g' input.txt > output.txt

あえて使う場面

ゼロ幅文字は厄介な存在だが、逆に便利な使い道もある。

SNS の自動リンク化を防ぐ

X(Twitter) では @ に続く文字列がメンション、. を含むものが URL、# に続くものがハッシュタグとして自動的にリンクになる。メールアドレスを文として載せたいだけなのに、勝手にメンションリンクになるのは困る。

// @ の直後にゼロ幅スペースを挿入
'@username'.replace(/@/g, '@\u200B')
// → '@\u200Busername'
// 見た目は @username のまま、リンク判定だけ途切れる

@u の間にゼロ幅スペースが入るだけで、X のリンク検出は「@ の後に何もない」と判断する。画面上は @username に見えるのに、クリックできるリンクにはならない。

この仕組みを使ったツールをツイートのリンク化回避ツールとして公開している。テキストを入れてボタンを押すだけで、メンション・URL・ハッシュタグのリンク化を防いだテキストが得られる。

長い文字列に改行位置のヒントを入れる

ZWS は「ここで改行してもいい」という情報を持つ。長い英単語や URL がコンテナからはみ出すとき、適当な位置に ZWS を挿入すると、ブラウザがそこで折り返してくれる。CSS の word-break で一律に割るより、意味のある区切り位置を指定できる。

https://example.com/very/long/path/to/resource
↓ スラッシュの後ろに ZWS を入れると、そこで折り返せる
https://example.com/very/​long/​path/​to/​resource

HTML では &#x200B; または <wbr> タグで同じことができる。<wbr> は「Word Break Opportunity」の略で、ゼロ幅スペースと同じ役割を HTML タグとして表現したもの。

テキストのフィンガープリンティング

機密文書を複数人に配布するとき、各コピーの異なる位置にゼロ幅文字を仕込んでおくと、流出したテキストからどのコピーが漏れたか特定できる。実際にニュースメディアや企業の内部文書で使われているとされる手法で、目に見えないため受け取った側は気づきにくい。

注意点

  • ゼロ幅文字はスクリーンリーダーの読み上げに影響することがある。アクセシビリティを考えると、公開コンテンツでの多用は避けたい
  • サービスによってはゼロ幅文字を自動で除去する処理が入っている。投稿先で効くかどうかは事前に確認する
  • リンク化回避やフィンガープリンティングに使う場合、受け取った側がゼロ幅文字を除去すれば効果はなくなる。あくまで「気づかれにくい」だけで「破れない」仕組みではない
  • プログラムのソースコードにゼロ幅文字が混入すると、コンパイルエラーや実行時の不具合の原因になる。コードレビューでは目視で発見できないため、リンターやエディタの設定で検出するのが確実

関連記事

よくある質問

ゼロ幅文字はどこから混入する?
Webページからのコピペ、テキストエディタの自動挿入、チャットアプリの変換処理などで混入する。目に見えないため気づきにくい。
ゼロ幅文字を検出するには?
テキストエディタの正規表現検索で [​-‍] を使うか、JavaScriptで文字列の .length と見た目の文字数を比較する方法がある。
ゼロ幅文字を除去するには?
JavaScriptなら str.replace(/[​-‍]/g, "") で一括除去できる。ターミナルではsedコマンドでも対応可能。
ゼロ幅文字をあえて使う場面はある?
SNSの自動リンク化を防ぐ、長い英単語やURLに改行位置のヒントを入れる、アラビア文字の結合制御など、意図的に挿入するケースがある。