よそから持ってきたテキストを整える

貼り付けたテキストが妙な振る舞いをする原因となる不可視文字、置き換えられた約物、改行コード、正規化形式、大文字と小文字の規則を取り上げ、それらを直す順序を示します。

PDF、表計算のセル、CMS のフィールド、メールクライアント、チャットアプリから持ってきたテキストは、それを生成したソフトウェアの書式上の判断を一緒に引き連れてきます。そして、その判断のほとんどは画面上では見えません。結果として、見た目は正しく、印刷しても正しいのに、理由が見当たらないまま比較、検索、JSON のパース、データベースの検索に失敗する文字列ができあがります。

目に見えない文字

Unicode にとって、これらは実在する文字です。ただ、見える形を持たないか、別の文字と同じ形をしているだけです。

文字コードポイント主な出どころ壊れるもの
ノーブレークスペースU+00A0HTML の  、Word、PDF のレイアウト空白での分割、完全一致
狭いノーブレークスペースU+202Fフランス語の組版、Word からの日付同じ。ただしさらに気づきにくい
全角スペースU+3000CJK の入力メソッド広い空きに見えるだけ
ゼロ幅スペースU+200BCMS の改行位置ヒント、コピーした Web テキスト不可視で、しかも空白文字として扱われない
ゼロ幅非接合子 / 接合子U+200C、U+200Dペルシア語、インド系文字、絵文字のシーケンス単語境界、文字数
ワードジョイナーU+2060組版ツール見た目には何も、テキスト処理にはすべて
ソフトハイフンU+00ADWord のハイフネーション、PDF の書き出し単語が自分自身と一致しなくなる
バイトオーダーマークU+FEFFUTF-8 ファイルの先頭バイト先頭行の最初のフィールド
左横書き / 右横書きマークU+200E、U+200F双方向テキスト数値の周りに紛れ込む制御文字
行区切りと段落区切りU+2028、U+2029一部の Mac アプリやレイアウトアプリ行分割、古い JavaScript パーサー

検索が失敗するのは、検索が比較しているのが形ではなくコードポイントだからです。PDF から得たものが New U+00A0 York で、こちらが普通の空白 U+0020 を使って New York と入力したなら、2 つの文字列は別物であり、その理由は誰も教えてくれません。

"New York".includes("New York")   false, the gap is U+00A0
"  text​".trim().length      5, the zero-width space survives

ノーブレークスペースは、ほとんどの trim 関数や、ほとんどの正規表現エンジンの \s にとっては空白文字です。そのため文字列の両端では消えるのに、分割が普通の空白を期待しているまさにその中間には残ります。ゼロ幅スペースはどこでも空白文字として扱われないので、トリムしても連続空白をまとめても手つかずで残ります。メールクライアントからコピーしたテキストで Email Extractor が何も返さないことがあるのも、これが理由です。アドレスの中にゼロ幅文字が 1 つあるだけで、パターンが一致しなくなります。

Hidden Character Detector は実際に何が入っているかを表示し、Unicode Escape は 1 つの値の正確なコードポイントを示します。ただし、見つけたものを片っ端から取り除いてはいけません。絵文字シーケンスの中のゼロ幅接合子や、ペルシア語やデーヴァナーガリーのゼロ幅非接合子は、ノイズではなく内容そのものです。

ワープロが勝手に置き換えた約物

オートコレクトは入力中に文字を組版用の文字へ置き換えます。そして、その置き換えは文書からテキストを持ち出しても付いてきます。

入力した文字実際に入っている文字コードポイント
'右シングルクォーテーションマークU+2019
""左右のダブルクォーテーションマークU+201C、U+201D
単語の間の -エヌダッシュまたはエムダッシュU+2013、U+2014
...水平省略記号U+2026
数字の前の -マイナス記号またはノーブレークハイフンU+2212、U+2011

散文なら問題ありませんが、機械が解析するものでは致命的です。

{ “name”: “Ada” }      unexpected token, only U+0022 is a JSON string quote
git commit -m “fix”    the shell sees three words, not one quoted argument
WHERE name = ‘Ada’     SQL syntax error near ‘
10\u201320                  not a number range, U+2013 is not a hyphen

CSV では、失敗がもっと静かに起きます。パーサーがフィールドの囲み記号として認識するのはまっすぐなダブルクォートだけなので、ワープロが丸いクォートで囲んだ値は囲まれていないものとして扱われます。すると、その中のカンマで行が分割され、それ以降の列がすべてずれます。負の数に U+2212 を使っている列はテキストとして取り込まれ、合計から黙って除外されます。

対処法は、短い置換リストを用意して Find and Replace を使うことです。ただし適用先は、コード、CSV、キーになるテキストだけにしてください。公開する散文に対して実行すると、意図して使われていた約物まで潰してしまいます。

改行コードと行末の空白

Windows のツールは行を CR LF (U+000D U+000A) で終わらせ、Unix のツールは LF だけで終わらせます。ごく古い Mac の書き出しには、CR だけのものもまだあります。たいていのパーサーはこれらに対応できますが、素朴な分割は対応できません。

"UK\r\n" split on "\n"  ->  "UK\r"
"UK\r" === "UK"             false
"UK\r".length               3

表、ログ、差分ビューで同じに見える 2 つの文字列でも、復帰文字、行末の空白、タブの分だけ違っていることがあります。Text Diff がある行を変更ありと示すのに変更が見当たらないときの答えがこれです。貼り付けた列を Quote Lines や Join Lines に通したときにクォートの中へ余計な空白が入るのも、同じ原因です。

正規化は 1 回の処理で済ませ、CR LF と単独の CR をまとめて一致させてください (\r\n?\n に置換します)。2 段階に分けて置換すると、空行が二重になります。

1 つの文字に 2 つの書き方

Unicode では、アクセント付きの文字を 1 つのコードポイントで表すことも、基底文字に結合記号を続けて表すこともできます。どちらも正しく、そして両者は等しくありません。

形式é の格納のされ方JavaScript でのコードユニット数
NFC (合成済み)U+00E91
NFD (分解済み)U+0065 U+03012

macOS は歴史的に分解済みのファイル名を返してきましたし、いくつかの PDF 生成ツールや入力メソッドも分解済みのテキストを出力します。そのため、ある経路から来た値は、キーボードで打った同じ値と一致しません。

"é" === "é"                  false
"é".normalize("NFC") === "é" true

影響は等価判定にとどまりません。コードポイントを比較するソートでは、分解済みの形が素の e の隣に並び、合成済みの形は遠くに置かれるため、名前の一覧が 2 つの塊に分かれて返ってきます。文字数も形式によって変わるので、280 文字の上限や VARCHAR(50) のカラムでは問題になりますし、Text Statistics は同じに見えるテキストに対して異なる数値を報告します。切り詰めはさらに厄介です。分解済みの文字列を固定長で切ると、基底文字とアクセントの間で切れてしまうことがあり、そのアクセントが後続の文字に付いてしまいます。正規化していない入力に Truncate Text を使うと、末尾の文字が目に見えておかしくなることがあるのは、このためです。

互換形式である NFKC と NFKD はさらに踏み込み、単に似ているだけの文字も畳み込みます。合字の U+FB01 は fi に、全角の U+FF21 は A に、マイクロ記号 U+00B5 はギリシャ文字のミュー U+03BC に、上付きの ² はただの 2 になります。検索キーや重複排除キーには非常に有効ですが、表示するものには破壊的です。 が黙って x2 になってしまうからです。

実務上の規則はこうです。保存と表示には NFC、誰の目にも触れないキーにだけ NFKC を使います。

大文字と小文字の変換は単一の操作ではない

大文字化は 1 文字ごとの対応付けではありませんし、ロケールに依存しない処理でもありません。

  • ドイツ語の ß U+00DF は大文字化すると SS になるため、文字列が長くなり、小文字に戻しても元には戻りません。
  • ギリシャ語のシグマは、語末では ς、それ以外では σ に小文字化されます。これも往復変換を壊します。
  • トルコ語とアゼルバイジャン語には、点のない ı U+0131 と点付きの大文字 İ U+0130 があります。これらのロケールでは Iı に小文字化され、iİ に大文字化されます。

最後のものは本番環境で起きる典型的なバグです。ヘッダー名、ファイル拡張子、プロトコル文字列を小文字化するコードはどこでも動きますが、既定のロケールがトルコ語のマシンで動いた途端に破綻します。そこでは "FILE"fıle に小文字化され、file と一致しなくなります。Java と .NET では引数なしのメソッドがマシンの既定ロケールを使うため、toUpperCase()ToUpper() が落とし穴です。機械が読むものには不変ロケールを明示してください。比較が目的なら、小文字化よりケースフォールディング (Python の casefold()) のほうが適切です。ßss として扱ってくれるからです。

タイトルケースには唯一の定義がありません。「すべての単語の頭文字を大文字にする」とすると「The Lord Of The Rings」や「IPhone」ができてしまうのは、そのためです。多くのスタイルガイドでは、最初と最後の単語に加えて、冠詞、等位接続詞、短い前置詞を除くすべてを大文字にし、ハイフンでつながれた複合語は両方の要素を大文字のままにし、頭字語や、McDonald、O'Brien、iPhone のように途中に大文字を含む名前には手を触れません。

プログラミング向けの命名規則には、独自の境界問題があります。HTTPResponseCode をスネークケースに変換した結果は、大文字の連続を分割器がどう扱うかで完全に決まります。Case Converter は http_response_code を返し、素朴な実装は h_t_t_p_response_code を返します。

うまくいく順序

各手順は、直前の手順がすでに実行されていることを前提としています。順序を入れ替えると、互いに干渉します。

  1. エンコーディングを確定する。 café のような文字化けは、バイト列が誤ったエンコーディングでデコードされたことを意味します。症状に手当てをするのではなく、元のバイト列をデコードし直してください。BOM を取り除くのは、テキストのいちばん先頭にある場合だけにします。
  2. 改行コードを正規化する。 1 回の処理で LF にそろえます。
  3. 書式用の文字を取り除く。 ノイズだと判断したもの、つまりソフトハイフン、ゼロ幅スペース、ワードジョイナー、双方向制御マークです。これは正規化より前に行ってください。基底文字とアクセントの間に不可視文字が挟まっていると、NFC はそれらを合成できないからです。
  4. 紛らわしい約物を置き換える。 出力先がコード、CSV、JSON、キーである場合に限ります。
  5. Unicode 正規化を適用する。 既定は NFC です。
  6. ここで空白を処理する。 残っている特殊な空白を U+0020 に変換し、連続をまとめ、最後にトリムします。これを手順 3 より前に行うと、ノーブレークスペースと普通の空白が隣り合っていた箇所に二重空白が残ります。手順 2 より前にトリムすると、各行の最後の値に復帰文字が貼り付いたまま残ります。
  7. 大文字と小文字の変換は最後に行う。 ロケールを明示的に指定します。

単語単位の操作も手順 3 の後に置きます。ソフトハイフンが残ったまま Split Text でテキストを分割すると、件数が水増しされ、単語が半端に切れます。Censor Text をはじめ、語句を走査する処理すべてに同じことが当てはまります。

ここまでやっても値が一致しないときは、それを眺めるのをやめてください。長さを出力し、コードポイントにエスケープし、エスケープした 2 つの形を直接比較します。画面上では決して分からない違いも、そこでは一目瞭然です。