CJKの改行ルール — 日本語・中国語・韓国語で折り返しが違う理由

レイアウト側は単語で改行しているのではなく、折り返してよい位置(break opportunity)で改行しています。そして、その位置の作られ方が言語ごとに違います。日本語と中国語はほぼどの文字間にも折り返し位置があり、韓国語は単語の間に空白があるので空白が折り返し位置になり、タイ語には空白が1つもないので辞書を持たない描画系はそもそも折り返せません。

この判断を担っているのがUnicodeの行分割アルゴリズム(UAX #14)です。各文字には行分割クラスが割り当てられ、隣り合う2文字のクラスの組で「ここで切ってよいか」が決まります。日本語の禁則も、韓国語の音節の保護も、タイ語の辞書参照も、すべてこの1つの仕組みの中で表現されています。日本語の禁則そのものの詳細は日本語の改行が崩れる原因と禁則処理にあります。

「折り返してよい位置」は単語の区切りではない

同じ意味の短文を4言語で用意し、ブラウザやNode.jsが持つ単語分割器(Intl.Segmenter)に掛けると、言語ごとの前提の違いがそのまま出てきます。

  • 日本語と中国語は辞書による分割で、空白がない。上の結果は「ください」を「くだ」「さい」に、「開いて」を「開」「い」「て」に割っている。単語分割器の出力を折り返し位置の一覧として使ってはいけない。
  • 韓国語は空白で分かち書きされる。語単位は4つで、その間の空白3つがそのまま区切りとして返ってくる。
Intl.Segmenter(locale, { granularity: "word" })
Node.js 24.18.1 / ICU 78.3 で実行

ja  設定を開いて言語を選んでください         16文字 / 語単位 11
    設定 / を / 開 / い / て / 言語 / を / 選 / んで / くだ / さい
zh  请打开设置并选择语言                     10文字 / 語単位  6
    请 / 打开 / 设置 / 并 / 选择 / 语言
ko  설정을 열고 언어를 선택하세요             16文字 / 語単位  4
    설정을 / 열고 / 언어를 / 선택하세요   (空白3つが区切りとして残る)
th  เปิดการตั้งค่าแล้วเลือกภาษา              27文字 / 語単位  7
    เปิด / การ / ตั้ง / ค่า / แล้ว / เลือก / ภาษา   (空白は0)

実装は何を見て決めているのか — UAX #14の行分割クラス

UAX #14では、すべての文字にLine_Breakという性質が付いています。表意文字はID、閉じ括弧はCLやCP、開き括弧はOP、行頭に来られない文字はNS、感嘆符・疑問符はEX、空白はSP、ハングルは字母がJL・JV・JT、合成済み音節がH2・H3です。アルゴリズム本体は番号の付いた規則を順に適用する形で定義され、規則を要約したペア表も併記されています。表のマスには「切ってよい」「間に空白があるときだけ切ってよい」「切ってはいけない」の区別が書かれています。

日本語の禁則は、この表から自然に出てきます。IDの次にCL・CP・EX・NSが来る組は分割禁止なので、句読点や閉じ括弧が行頭に落ちることはありません。OPの後ろは分割禁止なので、開き括弧が行末に取り残されることもありません。

設定で変えられる部分もはっきりしています。小書きの仮名や長音記号はCJ(Conditional Japanese Starter)というクラスで、既定ではNS(行頭に来させない)として扱い、実装側の調整でID(来てよい)に解決してもよいと決められています。CSSでこれを選ぶのがline-breakですが、許す側に立つのはlooseだけです。normalとstrictはどちらも小書きの仮名や長音記号の前での分割を禁じ、この2つを分けるのは〜(U+301C)や゠(U+30A0)のようなCJKのハイフン類の前で切れるかどうかです。初期値はautoで、どの制限を使うかはUAに任され、行の長さによって変えてもよいとされています。個々の文字のクラスはUAX #14の行分割クラスとペア表の節、値の定義はCSS Text Module Level 3のline-breakの節で確認してください。

以下はPythonのunicodedata(UCD 13.0.0)で確認した結果です。WとFはどちらも2カラム分を占めるので、行に何文字入るかの計算では同じ扱いでよいものです。

  • A(Ambiguous)の文字は、環境によって1カラムにも2カラムにもなる。三点リーダー(…)と、簡体字中国語で使われる引用符(“ ”)がこれに当たる。等幅前提で行幅を計算しているUIでは、ここだけ想定より1カラムずれて末尾がはみ出す。ピクセルフォントでの字幅の扱いは日本語と英語のピクセルフォントで扱っている。
U+3001 IDEOGRAPHIC COMMA                      Po  East_Asian_Width=W
U+3002 IDEOGRAPHIC FULL STOP                  Po  W
U+FF0C FULLWIDTH COMMA                        Po  F
U+300C LEFT CORNER BRACKET                    Ps  W
U+300D RIGHT CORNER BRACKET                   Pe  W
U+30FC KATAKANA-HIRAGANA PROLONGED SOUND MARK Lm  W
U+3063 HIRAGANA LETTER SMALL TU               Lo  W
U+301C WAVE DASH                              Pd  W    <- normal と strict の境目
U+30A0 KATAKANA-HIRAGANA DOUBLE HYPHEN        Pd  W    <- normal と strict の境目
U+002E FULL STOP (ASCII)                      Po  Na
U+2026 HORIZONTAL ELLIPSIS                    Po  A    <- 幅が「曖昧」
U+201C LEFT DOUBLE QUOTATION MARK             Pi  A    <- 同じ

中国語 — 折り返しは日本語と同じ、句読点と字形が違う

簡体字・繁体字の漢字はどちらもIDなので、折り返しの挙動は日本語とほぼ同じです。日本語で文字単位の折り返しが正しく動いている描画系なら、中国語でも動きます。

  • 読点の文字が違う(上の表)。翻訳ファイルに日本語の読点が混じった中国語が来ても折り返しの挙動は変わらないので、画面を読むまで気づかない。言語ごとに使ってよい句読点を決めて検査するしかない。
  • 台湾の組版では。と,が全角の枠の中央に置かれ、日本や中国大陸では左下に置かれる。文字コードは同じで、どちらの字形になるかはフォントが持つロケール別字形と、そのテキストに付けた言語タグで決まる。使うフォントがどのロケール字形を持っているかは、フォントの公式ドキュメントで確認する。タグの付け方はBCP 47の言語タグにまとめてある。
読点・句点・引用符(確認済みのコードポイント)

ja        、U+3001   。U+3002   「」U+300C U+300D
zh-Hans   ,U+FF0C   。U+3002   “”U+201C U+201D
zh-Hant   ,U+FF0C   。U+3002   「」U+300C U+300D
ko         ,U+002C    .U+002E   (ラテン文字の句読点+分かち書き)

韓国語 — 空白で折り返す言語なのに、既定では単語の途中で切れる

韓国語はハングルを空白で区切って書きます。ところがCSSの既定(word-break: normal)ではハングルもCJKの扱いになり、音節の間でも折り返しが起きます。結果として、空白という正しい折り返し位置があるのに、単語の真ん中で行が切れます。

これを止めるのがword-break: keep-allです。空白と明示的な改行位置だけで折り返します。ただし副作用があり、空白のない長い連なり(URLのような文字列、長い固有名詞)は1文字も切れなくなって枠外に出ます。keep-allを指定するなら、非常時の弁としてoverflow-wrap(break-word または anywhere)を併せて指定します。正確な定義はCSS Text Module Level 3の該当節で確認してください。

もう1つ、自作の折り返し処理で踏みやすい落とし穴があります。ハングルの音節は合成済みの1文字として表現されるのが普通ですが、NFDで正規化されると字母に分解されます。

  • コードポイント単位で行を切る自作処理は、NFDの韓国語で音節を字母に割ってしまう。表示は「한」ではなく分解された形になり、行頭に子音だけが残る。
  • 書記素クラスタ単位で数えれば、NFDでも音節は2つのまとまりとして保たれる。UAX #14の規則も字母の連なりの内部での分割を禁じているので、規格に沿った実装なら安全である。
  • NFDの韓国語はmacOS経由のファイルなどで実際に混ざる。取り込み時にNFCへ正規化しておくのが確実。コードポイントの数と見た目の文字数が合わない問題は絵文字とサロゲートペアでも起きる。
"한글"
  NFC: 2コードポイント  U+D55C U+AE00
  NFD: 6コードポイント  U+1112 U+1161 U+11AB U+1100 U+1173 U+11AF
                        (CHOSEONG HIEUH / JUNGSEONG A / JONGSEONG NIEUN ...)
  書記素クラスタ(Intl.Segmenter granularity "grapheme", ko): どちらも 2

タイ語など空白のない言語 — 辞書がなければ折り返せない

タイ語には単語の間の空白がありません。空白は句や文の区切りとして使われます。UAX #14はタイ語・ラオ語・クメール語・ミャンマー語などの文字にSA(complex context)というクラスを与え、解決には辞書や形態素解析が必要だと述べています。アルゴリズム単体ではタイ語を折り返せません。

  • 描画系がタイ語の辞書を持たない場合、タイ語は1つの長い塊として扱われて全く折り返さないか、任意の位置で切られる。使っているテキストシステムがタイ語の行分割に対応しているかは、公式ドキュメントの行分割・テキスト整形の節で確認する。ICUを内部で使っているものは対応していることが多い。
  • 全文字間で切る設定をタイ語の代用にしない。折り返しは起きるが、単語の途中で切れた文字列になり、読める形にはならない。
  • 行の高さも見る。結合文字が上下に積まれるので、ラテン文字の高さで組んだ行では上のトーン記号が切れる。文字が四角い箱になる場合は折り返しではなくフォント側の問題で、文字が豆腐(四角い箱)になる原因の方が近い。
เปิดการตั้งค่าแล้วเลือกภาษา

  コードポイント 27 / 空白 0 / 結合文字(category Mn) 6 / 書記素クラスタ 21
  結合文字: U+0E34 U+0E31 U+0E49 U+0E48 U+0E49 U+0E37

言語ごとの設定と、出荷前の検査

設定側は3つのプロパティの役割分担を押さえれば足ります。word-breakが単語の内部で切ってよいか(normal / break-all / keep-all)、line-breakが小書きの仮名などの前で切ってよいか(auto / loose / normal / strict)、overflow-wrapが収まらないときの非常措置(break-word / anywhere)です。ゲームエンジンはCSSとは別の設定系を持ちます。Unityのテキスト描画(TextMesh Pro)とUnreal Engineのテキスト表示には、それぞれ行分割やCJKの扱いに関する設定があるので、公式ドキュメントの該当項で確認してください。

原文に手で入れた改行が残っている文字列は、描画を見る前に検査できます。以下の正規表現は実際に実行し、記載した結果を確認しています。

  • 文字クラスには半角の括弧と句読点も入れる。日本語の文に半角の(が混ざるのは珍しくなく、全角だけを列挙した検査は素通りする。最初に書いた版は全角だけを見ていて、半角の括弧の例を取りこぼした。
  • 韓国語の検査が言えるのは「改行の前後がハングルで、間に空白がない」ことだけである。単語の途中で切ったのか、空白を消して改行に置き換えたのかは区別できない。どちらも原文に手で改行を入れた結果なので、レビュー対象として挙げるには十分だが、これだけで不具合と断定はできない。
  • そもそも翻訳対象の文字列に改行を埋め込まないのが上位の対策である。文字列の受け渡しで何を決めておくかはローカライズキットの作り方にまとめてある。
  • 実機では、言語ごとに最長の訳文を一番狭いレイアウト(携帯機や小さいダイアログ)で表示して見る。
  • 韓国語は、空白のない長い連なりを含む文字列を1つ用意して確認する。keep-allを入れた直後に起きるはみ出しはここでしか出ない。
  • テキストに付けた言語タグが描画系まで届いているかも確認する。繁体字の句読点の位置やCJの扱いはタグで変わるので、タグが落ちていると選んだ設定が効かない。表記体系と地域の書き分けはUnicodeとCLDRの役割につながる話である。
言語ごとの設定
  ja / zh  word-break は既定のまま。禁則を厳しく当てるなら line-break: strict
  ko       word-break: keep-all + はみ出し対策の overflow-wrap
  th       辞書を持つテキストシステムを使う。設定では解決しない
  全言語    break-all はラテン文字や数字の連なりも割るので一律に掛けない

原文の文字列に対する検査(実行して確認した結果)
  const startForbidden = /\n[、。,.,.))」』】〕}]!!??:;:;ー・…っゃゅょ]/u;
  const endForbidden   = /[((「『【〔{[]\n/u;
  const korNoSpace     = /[가-힣]\n[가-힣]/u;

  "セーブデータを\n、上書きします。"      startForbidden: 一致
  "ロードしています\nっと…"              startForbidden: 一致
  "確認してください(\nはい/いいえ)"      endForbidden:   一致 (半角の括弧)
  "確認してください(\nはい/いいえ)"    endForbidden:   一致 (全角の括弧)
  "セーブデータを\n上書きします。"        いずれも不一致
  "언어를\n선택하세요"                    korNoSpace:     一致
  "설정을 열고 \n언어를 선택하세요"        korNoSpace:     不一致

関連記事