N 文字以上なら省略表示

「N 文字以上 / 以内」みたいなことを言われたときに考えること.

「文字」とは?

単に「文字」と言っても, それが指しているものが何かは自明ではない.

符号単位 (code unit)

JavaScript の場合, 文字列は UTF-16 としてエンコードされている*1ので, そのエンコードの単位である 16 bit ごとに分割するというのがこの方法. .length で取得できるのはこの符号単位の数で, .slice() に与えるのも符号単位で数えたインデックスとなっている.

> "あいうえおABCDE".length
10
> "あいうえおABCDE".slice(0, 5)
"あいうえお"

ところで Unicode には 16 bit で表せる数以上の「文字」 (U+0000 〜 U+10FFFF) が含まれるので, UTF-16 では 1 つの符号単位で表せない「文字」は 2 つの符号単位 (サロゲートペア) を使って表すことになる. 例えば絵文字 (🍣 = U+1F363 など) や, マイナーめな漢字 (𰻞 = U+30EDE など) が代表例. こういったものが文字列に含まれると, 長さの計算が意図と異なったり, 途中で打ち切った時に文字化けしたような見た目になってしまうことがあるので注意.

> "あいうえお🍣ABCDE".length
12
> "あいうえお🍣ABCDE".slice(0, 6)
"あいうえお\ud83c"

符号単位は長さが決まっているので, データサイズとしての文字列の長さを測る場合にはこれが適している. ただし JavaScript.length で計算できるのはあくまで UTF-16エンコードしたときのサイズであって, UTF-8 などエンコーディングが変わればサイズも変わることに注意. 表示する用途で文字列を区切るのに使うのは微妙.

符号位置 (code point)

Unicode に含まれる「文字」に与えられる符号 U+0000 〜 U+10FFFF のそれぞれは符号位置と呼ばれていて, 上記のようなサロゲートペアに対してはこの単位で区切ることでマシな結果が得られる. JavaScript では文字列のイテレータがこの単位で分割してくれるようになっている.

> [..."あいうえお🍣ABCDE"].length
11
> [..."あいうえお🍣ABCDE"].slice(0, 6).join("")
"あいうえお🍣"

一方でこれにもまだ問題があって, この世界には複数の符号位置を組み合わせて 1 つの「文字」を作るようなものがある. 代表的なものは結合文字 ( = U+0065 U+0301) や国旗の絵文字 (🇯🇵 = U+1F1EF U+1F1F5), その他絵文字のスキントーンや組み合わせなどがある. 結合文字については .normalize() で 1 つの符号位置を使うように正規化できることもあるが, 絵文字などはそうもいかない.

> [..."あいうえお🇯🇵ABCDE"].length
12
> [..."あいうえお🇯🇵ABCDE"].slice(0, 6).join("")
"あいうえお🇯"

Unicode の符号位置の並びを考えたい時は符号位置を使うと良い (それはそう). やはり表示する用途で文字列を区切るのに使うのは微妙.

書記素 (grapheme)

人間が素朴に考える「文字」に最も近いと思われる概念が書記素で, 名前の通りこれ以上分解すると記号の意味が成り立たなくなる単位を指す. JavaScript では Intl.Segmenter を使うとこの単位で文字列を分割できる. 2024 年 4 月に Firefox 125 がリリースされたことで, 主要なブラウザやサーバーサイドのランタイム全てで Intl.Segmenter を使えるようになった (それまではサードパーティのライブラリを使う必要があった).

> const segmenter = new Intl.Segmenter("ja-JP", { granularity: "grapheme" })
> [...segmenter.segment("あいうえお🇯🇵ABCDE")].length
11
> [...segmenter.segment("あいうえお🇯🇵ABCDE")].slice(0, 6).map((x) => x.segment).join("")
"あいうえお🇯🇵"

欠点があるとすれば, 書記素の区切り方は Internationalization API の仕様*2では定められておらず, 環境によって結果が変わり得ることだが, 仕様にも補足があるように大体の実装は Unicode が定めているガイドライン*3に従っているはずで, ほぼほぼ同じ結果が得られるだろう (反例があったら教えてください).

省略表示などで文字列を区切りたいときは, 書記素を使っておけば概ね自然な結果が得られるはず.

ここまで 0.1 秒

他にも, 単に「N 文字」と言った場合, 暗黙の内に「日本語で」など特定の言語が仮定されている可能性がある. 例えば日本語と英語を比較すると, 平均的な文字の幅や, 同じ文字数で表現できる内容 (密度) が異なるので, 日本語の場合は N 文字までのところを, 英語では倍の 2 N 文字までとしたい, などがあるかもしれない.

*1:実際のところ ASCII の範囲だけ扱う時には無駄が多いので, 実体は必ずしもそうなってはいないかもしれないが, 少なくとも表面上は UTF-16 である

*2:https://tc39.es/ecma402/#sec-findboundary

*3:https://unicode.org/reports/tr29/