背景
私は文字列の比較でよくハマります。
目で見て同じなのに === が false になるやつ…。
だいたい犯人はゼロ幅スペースで、毎回「またか」と思いながら手で探していました。
いいかげん道具にしようと思って、不可視文字チェッカーというツールを作りました。
https://hashitosystem.com/tools/invisible/
貼り付けた文字列から、目に見えない文字を見つけて位置を出すだけのものです。
作ってみたら、いちばん考えることになったのが「位置をどう数えるか」でした。
用語
不可視文字、と雑に呼んでいますが、実際には性質の違うものが混ざっています。
- ゼロ幅スペース(U+200B)… 幅ゼロ。Webページからのコピーや翻訳サービスの出力で混ざる
- ノーブレークスペース(U+00A0)… 空白に見えるが別の文字。HTMLの
がこれ - BOM(U+FEFF)… ファイル先頭に付く印。Excelは喜ぶがJSONパーサは死ぬ
- 異体字セレクタ(U+FE00〜、U+E0100〜)… 直前の漢字の字形を切り替える文字
コードポイントというのはUnicodeが文字に振った通し番号のことで、U+200B の 200B の部分です。
一方 JavaScript の文字列は UTF-16 という形式で持たれていて、コードポイントが U+FFFF を超える文字は2つの単位に分けて格納されます。
この2つ組をサロゲートペアと呼びます。
何が問題だったか
最初、素直にこう書いていました。
for (var i = 0; i < s.length; i++) {
var cp = s.charCodeAt(i);
// cp が対象なら found に push
col += 1;
}
s は入力欄に貼られた文字列、col は表示用の桁番号です。
これで動くには動きます。
ただ、絵文字を含む文字列を入れると桁がズレます。
具体的には "😀" + ZWSP(絵文字のあとにゼロ幅スペース)を入れたとき、s.length が 3 なので、ゼロ幅スペースの桁が 3 と出ます。
でもエディタで見れば、それは2文字目です。
"😀" は s.length が 2 です。
charCodeAt(0) と charCodeAt(1) はサロゲートペアの片割れずつを返すので、そもそも文字として意味を持ちません。
直した形
コードポイント単位で回すようにしました。
for (var i = 0; i < s.length; ) {
var cp = s.codePointAt(i);
var w = cp > 0xFFFF ? 2 : 1; // サロゲートペアなら2単位ぶん進める
var t = targetOf(cp); // 対象の不可視文字かを判定して種別を返す
if (t) found.push({ cp: cp, kind: t.kind, line: line, col: col });
if (cp === 0x0A) { line += 1; col = 1; } else { col += 1; }
i += w;
}
codePointAt はサロゲートペアを繋いだ本来のコードポイントを返します。
i の進み幅を自分で決めているのは、対象文字を「そのままの並びで」残したい処理(掃除する側)と同じ走査を使い回すためです。
for...of でも文字単位で回れますが、置換する側で元の文字列を切り出す都合があって、添字を持つ形にしました。
結果はこうなります。
入力: "😀" + U+200B
before … col = 3(UTF-16のコード単位で数えている)
after … col = 2(コードポイントで数えている)
このズレは、掃除する側でも効きます。
s[i] で1単位ずつ拾って組み立てると、サロゲートペアの片割れだけを取り出す瞬間ができて、絵文字が壊れます。
s.substr(i, w) で幅ぶん取るようにしてから壊れなくなりました。
なぜ桁にこだわったか
このツールの価値は「見つけた」ではなく「どこにあるか」だと思っています。
ゼロ幅スペースが3個ある、と言われても直せません。
2行目の12桁目、と言われれば直せます。
その桁がエディタの表示とズレていたら、道具として成立しない…。
逆に言うと、位置を人に見せないツールならここまで気にしなくてよかった気もします。
結論
文字列を1文字ずつ回す処理を書くときは、その「1文字」がUTF-16のコード単位なのかコードポイントなのかを最初に決めてください。
人に位置を見せる用途ならコードポイント、バイト数やストレージの話ならコード単位です。
length と charCodeAt で書き始めた時点で前者は選べなくなっているので、決めるのは書く前です。
本記事はAI補助で執筆した、個人開発の紹介記事です。