コードユニット、コードポイント、書記素 — 1 つの文字列に 3 つの長さ
「この文字列はどれくらい長いか?」と尋ねると、JavaScript、Unicode、そしてユーザーがそれぞれ違う数字を返してきます。String.prototype.length は UTF-16 コードユニット — JavaScript 内部エンコーディングの 16 ビット単位 — を数えます。Basic Multilingual Plane の外にある文字はすべてサロゲートペアとして格納されるため、見た目は 1 つの記号でも '👍'.length は 2 です。
文字列をスプレッド([...s])するとコードポイント単位で走査されます。サロゲートペアの問題は解決しますが、シーケンスの問題は残ります。家族の絵文字 👨👩👧👦 は 4 つの人物コードポイントを 3 つの zero-width joiner(U+200D)で縫い合わせたものです: length は 11、コードポイント数は 7、それでも見えるグリフは 1 つ。国旗は 2 つの regional indicator 記号で、🇰🇷 の length は 4。肌の色の修飾子はベースの絵文字にさらにコードポイントを 1 つ積みます。
ユーザーが知覚する単位は書記素クラスタ(grapheme cluster)で、2021 年以降はプラットフォームがネイティブに数えられます: granularity を 'grapheme' にした Intl.Segmenter は、テキストカーソルが動くのとまったく同じ単位で文字列を分割します。文字カウンターが「ユーザーに見える文字数」として表示すべき数字はこれです。
バックエンドは 4 本目のものさしを持ち込みます。MySQL utf8mb4 の VARCHAR(10) はコードポイントを数え、Postgres の varchar(10) も同様です。しかしバイト数で制限する API は CJK 文字 1 つを UTF-8 の 3 バイト、絵文字を 4 バイトと見なします。同じ日本語文字列が JS の length チェックでは制限内なのに、バイトを数えるバックエンドでは超過になり得ます。制限を実際に執行するシステムと同じものさしで必ず検証してください。
const fam = '👨👩👧👦';
fam.length // 11 UTF-16 code units
[...fam].length // 7 code points (4 people + 3 ZWJ)
const seg = new Intl.Segmenter('en', { granularity: 'grapheme' });
[...seg.segment(fam)].length // 1 — what the user sees
'👍'.length // 2 — surrogate pair
'🇰🇷'.length // 4 — two regional indicatorsプラットフォーム制限の内訳 — Twitter の 280、SMS の 160、Google の 600px
現実の文字数制限が生の文字数であることはめったにありません。Twitter/X は 280 の重み付きユニットを許可します: ほとんどのラテン文字・数字・記号は重み 1、CJK 文字や絵文字などの広い範囲は重み 2 — だから日本語のツイートは最大 140 文字です。URL は実際の長さに関係なく常に 23 ユニットとして計上されます。t.co のラップが長さを正規化するためです。
SMS はさらに奇妙です。テキスト全体が GSM-7 アルファベットに収まれば 1 通 160 文字。1 文字でも外れると — 絵文字、ワープロから貼り付けたカーリーアポストロフィ、一部のアクセント付き文字 — メッセージ全体が静かに UCS-2 に切り替わり、予算は 70 文字に落ちます。長いメッセージは連結セグメントに分割され、各セグメントがヘッダーバイトを差し引かれて GSM-7 なら 153 文字、UCS-2 なら 67 文字が残ります。スマートクォート 1 つで課金 1 通が 3 通になり得ます — 大量送信 SMS の請求書で頻繁に発覚する問題です。
そのほか: Instagram のキャプションは 2,200 文字とハッシュタグ 30 個が上限。Google はタイトルタグを描画後のピクセル幅で切り詰めます — 約 600px、文字幅に応じておおよそ 50〜60 文字(WWW は iii よりずっと幅広)— メタディスクリプションは 155〜160 文字前後です。実務上の結論: 対象がコードユニットを数えるのか、重み付きユニットか、GSM-7 の septet か、ピクセルかを把握し、lorem ipsum ではなく代表的な実テキストでテストすることです。
単語カウントは言語依存 — CJK は空白ルールを壊す
空白で分割する方式は、英語・ドイツ語・スペイン語では堅実な単語カウンターです。しかし中国語や日本語の文にはきっかり 1 を返します — これらの文字体系は分かち書きをしません。CJK の本当の単語分割には辞書か統計モデルが必要です: 日本語では MeCab が標準の形態素解析器、中国語では jieba、ブラウザでは granularity を 'word' にした Intl.Segmenter が依存ゼロで ICU の辞書ベース分割を提供します。
韓国語は中間に位置します。スペースは使いますが、その単位は語節(単語+助詞のまとまり)なので、空白カウントは辞書的な単語ではなく語節数を数えることになります。実務では韓国語の文章はどのみち文字数で測られます。学校や出版界で今も参照される 200 字詰め原稿用紙の伝統から受け継いだ慣習です。
この区別にはお金が絡みます。プロの翻訳は英語系の言語では原文単語単位、日本語・中国語では文字単位で課金され、エージェンシーは言語の境界をまたいで見積もる際、公表された換算係数 — 英語 1 単語あたり CJK 2 文字前後のオーダー — を適用します。学術・法務の分量制限も同じ分かれ方をします: 日本語のジャーナルは字数を、英語のジャーナルは単語数を指定します。
多言語ユーザーに単語数を見せるプロダクトなら、Intl.Segmenter の isWordLike なセグメントを数えてください。同じコードパスで英語にも CJK にも言語学的に妥当な数字を返せます。
function countWords(text, locale = 'en') {
const seg = new Intl.Segmenter(locale, { granularity: 'word' });
return [...seg.segment(text)].filter(s => s.isWordLike).length;
}
countWords('The quick brown fox'); // 4
countWords('日本語は分かち書きをしない', 'ja'); // dictionary-segmented
'日本語は分かち書きをしない'.split(/\s+/).length; // 1 — naive split failsNFC と NFD — ピクセルは同じままカウントだけが変わる正規化
Unicode では、同じ見た目のテキストに複数のエンコーディングが存在することが珍しくありません。韓国語の音節 한 は、合成済みコードポイント 1 つ(U+D55C)としても、3 つの結合ジャモ(初声 ᄒ、中声 ᅡ、終声 ᆫ)としても存在します。é は単一コードポイント U+00E9 か、e に結合アキュートアクセント U+0301 が続く形かのどちらかです。NFC が合成形、NFD が分解形。両者はピクセル単位で同一に描画され、比較すると等しくありません。
Mac ではこれは机上の話ではありません。HFS+ はファイル名を分解形の変種で保存しており、macOS のいくつかの層は今も NFD テキストを渡してきます — Finder から韓国語のファイル名をコピーしてカウンターに貼り付けると、한글.txt は 6 文字ではなく 10 文字と報告されます。macOS からアップロードされたファイル名も同様です: バックエンドの maxlength チェックが、手入力のテキストは通し、見た目がまったく同じ貼り付けテキストは弾く、ということが起こります。
2 つ目の症状は等価性です。片方が NFC、もう片方が NFD だと 'é' === 'é' が false になり得て、重複排除、検索マッチング、unique 制約が静かに壊れます。修正は 1 行 — 数える・比較する・ハッシュ化する前に str.normalize('NFC') を呼ぶこと。正規化は冪等で高速です。Intl.Segmenter による書記素クラスタのカウントはほぼ影響を受けません。한 のどちらのエンコーディングも 1 クラスタを成すからで、人間に見せる数字なら書記素を数えるべきだというもう一つの論拠になります。
const nfc = '한'.normalize('NFC'); // U+D55C
const nfd = '한'.normalize('NFD'); // U+1112 U+1161 U+11AB
nfc.length // 1
nfd.length // 3 — same pixels, three code units
nfc === nfd // false
'한글.txt'.normalize('NFD').length // 10
'한글.txt'.normalize('NFC').length // 6読了時間の算数 — 毎分 238 語とその他の根拠ある数字
読了時間の推定は当てずっぽうに見えますが、測定された基準値があります。Brysbaert の 2019 年のメタ分析(約 190 の研究)は、英語ノンフィクションの黙読速度を毎分約 238 語と推定し、フィクションはやや速い 260 前後です。Medium の読了時間アルゴリズムは 265 wpm を使い、最初の画像に 12 秒を加算し、以降の画像ごとに 1 秒ずつ減らして下限 3 秒まで下げます。日本語と中国語では計画値は代わりに文字数で語られます — 快適な黙読でおおよそ毎分 400〜600 文字です。
平均単語長が換算を可能にします。英語は 1 語あたり約 4.7 文字、後続のスペースを含めれば約 5.7 文字。したがって 1,000 語の記事は約 5,700 文字で、238 wpm なら 4 分あまりで読めます。同じ算数で制限も翻訳できます: 155 文字のメタディスクリプションには英単語およそ 25 個、160 文字の GSM-7 SMS にはおよそ 28 個が入ります。
音声は黙読よりはるかに遅い — ナレーションやカンファレンス登壇は毎分 130〜150 語程度 — だから「5 分で読める」記事が 9 分のボイスオーバー台本になるのです。密度は編集上の有用なシグナルでもあります: 平均単語長が 6 文字を大きく超えていたら、専門用語の多いテキストであり、どんな単語ベースの公式の予測よりも遅く読まれます。