前の記事で、受け取った本文を上限で切るときに slice(0, N) を使っていた失敗と、末尾のサロゲートだけを見る守りでは国旗や ZWJ でつないだ絵文字、結合濁点の『が』をまだ割ることを数えました。その守りを、Intl.Segmenter の書記素(人が1文字と見るまとまり。正確には UAX #29 の拡張書記素クラスタ)の境界で切る形に直しました。
直したあと、4つの切り方を同じ文字列のすべての切る位置で並べて数え直しました。割れなくなったものと、直したあとにも残る1つの形を書きます。
この記事で書く文字数・位置は、どれも JavaScript の .length、つまり UTF-16 の符号単位です。
4つの切り方
| 名前 | やり方 |
|---|---|
| slice | src.slice(0, n) |
| サロゲートだけ守る | 切った末尾が上位サロゲートなら1つ手前で止める |
| 書記素の境界 |
Intl.Segmenter の境界のうち、n 以下で最も後ろで切る |
| 書記素+先頭だけ戻す | 上が 0(先頭の1書記素が n より長い)のときだけ、サロゲートだけ守る形に戻す |
4つ目が、実際に入れた形です。4つ目を置いた理由は下で書きます。
数えた結果
7つの文字列それぞれについて、1 から(長さ−1)までのすべての位置で切りました。『壊れた』は isWellFormed() が false(片割れのサロゲートが残った)、『書記素を割った』は切った長さが書記素の境界でない、『入る書記素まで捨てた』は境界で切れてはいるが、n 以下にもっと後ろの境界があった、です。
| 文字列 | 切る位置 | 切り方 | 壊れた | 書記素を割った | 空になった | 入る書記素まで捨てた |
|---|---|---|---|---|---|---|
| ASCII | 7 | slice | 0 | 0 | 0 | 0 |
| ASCII | 7 | サロゲートだけ守る | 0 | 0 | 0 | 0 |
| ASCII | 7 | 書記素の境界 | 0 | 0 | 0 | 0 |
| ASCII | 7 | 書記素+先頭だけ戻す | 0 | 0 | 0 | 0 |
| ひらがな | 5 | slice | 0 | 0 | 0 | 0 |
| ひらがな | 5 | サロゲートだけ守る | 0 | 0 | 0 | 0 |
| ひらがな | 5 | 書記素の境界 | 0 | 0 | 0 | 0 |
| ひらがな | 5 | 書記素+先頭だけ戻す | 0 | 0 | 0 | 0 |
| 絵文字(サロゲートペア) | 5 | slice | 1 | 1 | 0 | 0 |
| 絵文字(サロゲートペア) | 5 | サロゲートだけ守る | 0 | 0 | 0 | 0 |
| 絵文字(サロゲートペア) | 5 | 書記素の境界 | 0 | 0 | 0 | 0 |
| 絵文字(サロゲートペア) | 5 | 書記素+先頭だけ戻す | 0 | 0 | 0 | 0 |
| 国旗(地域指示子2つ) | 5 | slice | 2 | 3 | 0 | 0 |
| 国旗(地域指示子2つ) | 5 | サロゲートだけ守る | 0 | 2 | 0 | 0 |
| 国旗(地域指示子2つ) | 5 | 書記素の境界 | 0 | 0 | 0 | 0 |
| 国旗(地域指示子2つ) | 5 | 書記素+先頭だけ戻す | 0 | 0 | 0 | 0 |
| 家族(ZWJ でつないだ絵文字) | 9 | slice | 3 | 7 | 0 | 0 |
| 家族(ZWJ でつないだ絵文字) | 9 | サロゲートだけ守る | 0 | 6 | 0 | 0 |
| 家族(ZWJ でつないだ絵文字) | 9 | 書記素の境界 | 0 | 0 | 0 | 0 |
| 家族(ZWJ でつないだ絵文字) | 9 | 書記素+先頭だけ戻す | 0 | 0 | 0 | 0 |
| 濁点を後ろに付けた『が』 | 3 | slice | 0 | 1 | 0 | 0 |
| 濁点を後ろに付けた『が』 | 3 | サロゲートだけ守る | 0 | 1 | 0 | 0 |
| 濁点を後ろに付けた『が』 | 3 | 書記素の境界 | 0 | 0 | 0 | 0 |
| 濁点を後ろに付けた『が』 | 3 | 書記素+先頭だけ戻す | 0 | 0 | 0 | 0 |
| 先頭が家族の絵文字 | 8 | slice | 3 | 7 | 0 | 0 |
| 先頭が家族の絵文字 | 8 | サロゲートだけ守る | 0 | 6 | 1 | 0 |
| 先頭が家族の絵文字 | 8 | 書記素の境界 | 0 | 0 | 7 | 0 |
| 先頭が家族の絵文字 | 8 | 書記素+先頭だけ戻す | 0 | 6 | 1 | 0 |
測ったのは Node v24.18.1(ICU 78.3・Unicode 17.0)です。
読み方は3つです。
- **書記素の境界で切ると、上の6つの文字列では、壊れた・割った・空になった・入る書記素まで捨てた がすべて 0 でした。**サロゲートだけ守る形が割っていた国旗2・家族6・濁点1は、どれも 0 になりました
- **『入る書記素まで捨てた』は、4つの切り方とも全位置で 0 でした。**ただし slice とサロゲートだけ守る形は、書記素を割るか上限ちょうどで切るので、定義上 0 にしかなりません。意味があるのは書記素の境界を使う2つで、こちらは
n以下で最も後ろの境界を採っているので、手前で止まりすぎることはありません - **最後の1行だけ形が違います。**先頭が家族の絵文字(1書記素で8符号単位)の文字列を、8より小さい上限で切ると、
n以下にある書記素の境界は 0 しかありません。書記素の境界だけで切ると、7か所すべてで空文字列になります
先頭の1文字が上限より長いとき
空文字列を返すと、上限が小さいときに本文が丸ごと消えます。保存する本文が空になるのは、途中で切れるより悪いと判断しました。そこで、境界が 0 しか無いときだけ、サロゲートだけ守る形に戻しています(4つ目)。
その代わり、その7か所のうち6か所では書記素を割ります(壊れた文字は作りません)。割るか、全部捨てるかの二択で、割るほうを選んだということです。
さらに、戻した先でも空になる位置が1つ残りました。上限が 1 のときです。先頭が上位サロゲートなので、サロゲートだけ守る形は1つ手前の 0 で止まります。上限 2〜7 でも、割らずに空でない本文を残す切り方はありません(この文字列の境界は 0・8・9 だけです)。上限 1 ではさらに、壊れた文字を作らずに空でない本文を残す方法すらありません(長さ1で切ると片割れの上位サロゲートになります)。
私のサーバの上限は 50,000 符号単位なので、この形が起きるのは 50,000 符号単位を超える1書記素から始まる本文だけです。元の長さを記録しはじめた 2026-08-28 の夜以降、実際に届いたメッセージで 50,000 を超えたものは、まだ1通もありません(それより前は 5,000 で切っていて、元の長さの記録がありません)。ですから、この直しが実害を防いだ実績は 0 件です。
後ろを読まずに打ち切る
Intl.Segmenter の segment() は、反復するたびに次の書記素を返します。上限を超えた時点で break すれば、その後ろは反復しません(ICU が内部でどこまで走査しているかは測っていません)。
1,400,000 符号単位の文字列(7符号単位・3書記素の並びを 200,000 回)を 50,000 で切ったとき、読んだ書記素の数を数えました。
| 読み方 | 読んだ書記素 |
|---|---|
上限を超えたら break
|
21,430 |
| 最後まで読む | 600,000 |
[...seg.segment(src)] のように一度配列にしてから探すと、後ろの 578,570 個まで読むことになります(測ったのは配列化ではなく、for...of で最後まで回した回数です。個数は同じになります)。時間は測っていません(数えたのは読んだ個数だけです)。
ロケールで境界は変わったか
new Intl.Segmenter("ja" | "en" | "ar" | 既定, { granularity: "grapheme" }) の4つで、上の7つの文字列の境界を比べました。**7つとも4つのロケールで同じでした。**これは7つの文字列についての結果で、すべての文字について同じだとは確かめていません。
再現スクリプト
依存は0で、ファイルを書かず、通信もしません。Node 20 以降の API(String.prototype.isWellFormed と Intl.Segmenter)を使います。動かしたのは Node v24.18.1 だけです。Intl を外してビルドした Node では Intl.Segmenter が無いので動かないはずですが、試していません。
// 上限で文字列を切るとき、Intl.Segmenter の書記素境界で切ると何が変わり、何が残るかを数える。
// 使い方: node repro-grapheme-cut.mjs [--json]
// 依存0・読み取りのみ(ファイルを書かない・通信しない)。Node 20 以降(String.prototype.isWellFormed と Intl.Segmenter を使う)。
const CASES = [
{ name: "ASCII", text: "abcdefgh" },
{ name: "ひらがな", text: "あいうえおか" },
{ name: "絵文字(サロゲートペア)", text: "ab\u{1F600}cd" },
{ name: "国旗(地域指示子2つ)", text: "a\u{1F1EF}\u{1F1F5}b" },
{ name: "家族(ZWJ でつないだ絵文字)", text: "a\u{1F468}\u200D\u{1F469}\u200D\u{1F467}b" },
{ name: "濁点を後ろに付けた『が』", text: "あか\u3099い" },
{ name: "先頭が家族の絵文字", text: "\u{1F468}\u200D\u{1F469}\u200D\u{1F467}b" },
];
const seg = new Intl.Segmenter("ja", { granularity: "grapheme" });
// 書記素の境界(0 と末尾を含む)。
function ends(text, segmenter = seg) {
const set = new Set([0]);
for (const s of segmenter.segment(text)) set.add(s.index + s.segment.length);
return set;
}
// A: そのまま slice。
const naive = (src, n) => src.slice(0, n);
// B: 末尾が上位サロゲートなら1つ手前(書記素は見ない)。
const surrogateSafe = (src, n) => src.slice(0, /[\uD800-\uDBFF]/.test(src[n - 1] || "") ? n - 1 : n);
// C: 書記素の境界のうち n 以下で最も後ろ。途中で打ち切る(後ろを読まない)。
function graphemeOnly(src, n) {
let best = 0;
for (const { index } of seg.segment(src)) {
if (index > n) break;
best = index;
}
// 末尾ちょうどが n 以下なら全体が入る。
if (src.length <= n) best = src.length;
return src.slice(0, best);
}
// D: C が 0(先頭の1書記素が上限より長い)なら B に戻す。
function graphemeWithFallback(src, n) {
const c = graphemeOnly(src, n);
return c.length > 0 ? c : surrogateSafe(src, n);
}
const METHODS = { naive, surrogateSafe, graphemeOnly, graphemeWithFallback };
const rows = [];
for (const c of CASES) {
const e = ends(c.text);
const row = { name: c.name, codeUnits: c.text.length, graphemes: e.size - 1, cuts: 0 };
for (const m of Object.keys(METHODS)) row[m] = { illFormed: 0, splitsGrapheme: 0, empty: 0, droppedFitting: 0 };
for (let n = 1; n < c.text.length; n++) {
row.cuts++;
// n 以下で最も後ろの境界 = 割らずに残せる最大の長さ。
const bestFit = Math.max(...[...e].filter((x) => x <= n));
for (const [m, f] of Object.entries(METHODS)) {
const out = f(c.text, n);
const r = row[m];
if (!out.isWellFormed()) r.illFormed++;
if (!e.has(out.length)) r.splitsGrapheme++;
if (out.length === 0) r.empty++;
if (e.has(out.length) && out.length < bestFit) r.droppedFitting++;
}
}
rows.push(row);
}
// 打ち切りの効き目: 140万符号単位の文字列を 50,000 で切るとき、読んだ書記素の数。
const big = "あ\u{1F1EF}\u{1F1F5}か\u3099".repeat(200000); // 1回あたり 7 符号単位・3 書記素
const limit = 50000;
let visitedLazy = 0;
for (const { index } of seg.segment(big)) {
visitedLazy++;
if (index > limit) break;
}
let visitedAll = 0;
for (const _ of seg.segment(big)) visitedAll++;
const laziness = { codeUnits: big.length, limit, visitedWithBreak: visitedLazy, visitedWithoutBreak: visitedAll };
// ロケールで境界が変わるか(この7つの文字列について)。
const locales = ["ja", "en", "ar", undefined];
const localeSame = CASES.map((c) => {
const base = [...ends(c.text)].join(",");
return {
name: c.name,
sameForAll: locales.every((l) => [...ends(c.text, new Intl.Segmenter(l, { granularity: "grapheme" }))].join(",") === base),
};
});
const out = { node: process.version, icu: process.versions.icu, unicode: process.versions.unicode, rows, laziness, localeSame };
if (process.argv.includes("--json")) {
console.log(JSON.stringify(out, null, 2));
} else {
console.log(`node ${out.node} / icu ${out.icu} / unicode ${out.unicode}`);
const labels = { naive: "slice", surrogateSafe: "サロゲートだけ守る", graphemeOnly: "書記素の境界", graphemeWithFallback: "書記素+先頭だけ戻す" };
console.log("| 文字列 | 切る位置 | 切り方 | 壊れた | 書記素を割った | 空になった | 入る書記素まで捨てた |");
console.log("|---|---|---|---|---|---|---|");
for (const r of rows) {
for (const m of Object.keys(METHODS)) {
const x = r[m];
console.log(`| ${r.name} | ${r.cuts} | ${labels[m]} | ${x.illFormed} | ${x.splitsGrapheme} | ${x.empty} | ${x.droppedFitting} |`);
}
}
console.log(`打ち切り: ${laziness.codeUnits} 符号単位を ${laziness.limit} で切るとき、読んだ書記素 ${laziness.visitedWithBreak} / 打ち切らなければ ${laziness.visitedWithoutBreak}`);
console.log(`ロケール(ja/en/ar/既定)で境界が同じ: ${localeSame.map((x) => `${x.name}=${x.sameForAll}`).join(" / ")}`);
}
node repro-grapheme-cut.mjs で上の表と同じ28行と、打ち切りとロケールの2行が出ます。--json を付けると、表の元になった値をそのまま出します。
サーバに入れた実装は、この中の『書記素+先頭だけ戻す』と同じ考え方で書いています。7つの文字列の全42か所で、実装の結果とこのスクリプトの結果が1文字も違わないことを確かめました。
測っていないこと
- 測ったのはこのPC1台・Node v24.18.1(ICU 78.3)だけです。書記素の区切りは ICU と Unicode の版で変わることがあります
- 試した文字列は7つだけです。肌の色の修飾子・異体字セレクタ・インド系の文字などは試していません
- 時間は測っていません。上の打ち切りの表は、読んだ書記素の個数です
-
Intl.Segmenterが無い環境での振る舞い(サロゲートだけ守る形に戻す)は、コードを読んで確かめただけで、実際にその環境で走らせてはいません
やめたこと
- 『書記素の境界で切れば、どの上限でも空にならずに割らずに済む』と考えること(先頭の1書記素が上限より長いと、両方は満たせません)
- 書記素を探すために、文字列ぜんぶを先に配列にすること
- 上限で切ったことを、本文の外に記録しないこと(前の記事から変えていません)
Windows で AI や定期処理を無人で走らせている方向けに、タスク スケジューラの設定を読み取りだけで点検する診断スクリプトを、次のページの後半に置いています。