ダイアクリティカルマーク
文字の上下に付加される補助記号。アクセント記号やウムラウトなど、発音の違いを示す。
ダイアクリティカルマーク (発音区別符号) とは、文字の上下や横に付加される補助記号の総称です。フランス語のアクセント記号 (é, è, ê)、ドイツ語のウムラウト (ä, ö, ü)、スペイン語のティルデ (ñ)、チェコ語のハーチェク (č, š, ž) などが代表例で、ラテン文字圏を中心に世界中の言語で使われています。これらの記号は単なる装飾ではなく、発音や意味の区別に不可欠な要素です。たとえばフランス語では ou (または) と où (どこ) のようにアクセント記号の有無で意味が変わります。
Unicode では、ダイアクリティカルマーク付きの文字を 2 通りの方法で表現できます。1 つは「合成済み文字」(NFC: Normalization Form Composed) で、é を単一のコードポイント U+00E9 として扱います。もう 1 つは「基底文字 + 結合文字」(NFD: Normalization Form Decomposed) で、e (U+0065) とアキュートアクセント (U+0301) を組み合わせて表現します。この二重表現は Unicode の設計上の特徴であり、既存の文字コードとの互換性を保ちつつ、あらゆる言語の文字を柔軟に表現するために導入されました。
実務では、この二重表現が文字列比較やソートで深刻な問題を引き起こします。見た目がまったく同じ「é」でも、NFC と NFD ではバイト列が異なるため、単純なバイト比較では一致しません。データベースの検索、ファイル名の照合、パスワードの検証など、文字列の同一性判定が求められる場面では Unicode 正規化を適用して表現を統一する必要があります。
ファイル名については、ファイルシステムごとの扱いを正確に押さえておく必要があります。分解形へ揃えていたのは macOS の旧ファイルシステムである HFS+ で、現行の APFS は与えられた形をそのまま保存します。実際に APFS 上で合成形の名前 (U+00E9 を含む) でファイルを作ると、保存されるのは合成形のままで、分解形の名前で開こうとしても同じファイルに解決されます。つまり APFS は「正規化を強制しない代わりに、どちらの形でも同一とみなす」挙動です。一方 Windows や Linux の一般的なファイルシステムは名前を正規化せず、比較も符号位置単位で厳密に行うため、分解形と合成形の名前は別のファイルになります。この非対称が、macOS で作ったファイルを他環境へ持ち込んだときに「同じ名前のファイルが 2 つある」「リポジトリに存在するはずのファイルが見つからない」という形で表面化します。Git では core.precomposeUnicode がこの吸収を担います。
よくある誤解として、ダイアクリティカルマークを単に「アクセント記号」と呼ぶケースがあります。アクセント記号はダイアクリティカルマークの一種にすぎず、セディーユ (ç)、オゴネク (ą)、マクロン (ā) なども含む広い概念です。また、日本語の濁点や半濁点も基底文字に付く記号という点では同類ですが、Unicode では 2 系統に分かれている点に注意が必要です。単独で幅を持つ「゛」(U+309B) と「゜」(U+309C) は結合文字ではなく記号として分類され、結合するのは U+3099 と U+309A のほうです。「か」+ U+3099 は正規化で「が」にまとまりますが、見た目の似た U+309B を付けても合成されず 2 文字のままです。濁点を扱うコードでは、どちらの符号位置が来ているかを確認しないと、正規化しても直らない不一致が残ります。
プログラミングにおいては、JavaScript の String.prototype.normalize() メソッドや Python の unicodedata.normalize() 関数を使って正規化を行います。Web アプリケーションでフォーム入力を受け取る際には、サーバー側で NFC 正規化を適用してからデータベースに保存するのが一般的な手法です。
ここで注意したいのが、NFC 正規化をかければ必ず 1 コードポイントに縮むという思い込みです。合成済み文字が Unicode に用意されている組み合わせだけが合成されます。e + U+0301 は U+00E9 に、z + U+030C は U+017E にまとまりますが、q + U+0301 や z + U+0304 は合成済み文字が存在しないため、NFC をかけても 2 コードポイントのままです。したがって「正規化したのでコードポイント数 = 見た目の文字数になった」という前提でロジックを組むと、言語や記号の組み合わせによって崩れます。正規化の役割は長さを縮めることではなく、同じ文字の表記を一意に決めることだと捉えるのが正確です。
検索や重複判定では、正規化とは別に「アクセントを無視して一致させたい」という要求が出ます。これは正規化ではなく照合 (collation) の強度で調整する領域で、JavaScript なら new Intl.Collator('fr', { sensitivity: 'base' }) のように指定します。ただしこの強度では、記号の有無で意味が変わる語まで同一視されます。実測すると、既定の照合では ou と où は区別されますが、sensitivity: 'base' では一致 (比較結果 0) と判定されます。検索の入口では緩く当てて候補を広く拾い、最終的な同一性判定 (ログイン ID や一意制約) では緩めない、という使い分けが判断基準になります。なお既定の照合は NFC と NFD の差は吸収するため、比較目的なら事前に正規化しなくても一致します。
文字数カウントの観点では、NFD 形式のテキストは基底文字と結合文字が別々のコードポイントとしてカウントされるため、見た目の文字数よりも多くなります。たとえば「café」は NFC では 4 文字ですが、NFD では 5 文字 (c, a, f, e, ́) としてカウントされます。正確な「見た目の文字数」を得るには、書記素クラスタ単位でカウントする必要があり、文字数カウントツールの実装においてダイアクリティカルマークの扱いは避けて通れない課題です。