Skip to main content
GDN8 PATIO

Web実装における文字列の扱い

このノートはWebサイトを実装するときの文字列の扱いについての備忘です。文字数の上限や入力の数え方、折り返しと省略、文言の組み立てを分けて残します。形や言語から見た観察はデザインノート 04 : 言語と形になります。

実装で確認すること

文字列を扱うときは、必要に応じて次を確認します。

  • 文字数の上限がある場合、人が見る一文字、UTF-16、バイト数のどれで数えるか
  • 長い語やURL、句読点をどの規則で折り返すか
  • 数やリンクを含む文言を、翻訳後も並べ替えられる単位で持てるか

プログラムが見ている単位

まずはUTF-8とUTF-16です。どちらも同じ文字を持つことが可能で異なるのは並べ方と数え方です。

持ち方 単位 主な用途
UTF-16 符号単位(16ビットの枠) JavaScriptの文字列、string.lengthmaxlength
UTF-8 バイト ファイル、通信、TextEncoder

多くの文字は枠を1つ使用し番号が大きい文字(表の𩸽や多くの絵文字)は枠を2つ使用します。この2つ組をサロゲートペアと呼びます。string.lengthが見ているのは人の一文字ではなくこの枠の数です。

UTF-8はファイルや通信でよく使用する持ち方です。Aは1バイト、は3バイト、𩸽は4バイトです。保存量はバイト数で見ます。

コードポイントと書記素クラスタ

コードポイントはUnicodeの番号そのものです。文字列を[...text]と書くと番号ごとに広がるので、.lengthで番号の数が取れます。string.lengthとはずれることがあります。

書記素クラスタは「見た目としての一文字」に近い単位です。結合したアクセントや複数の人物をつないだ絵文字を表示どおり1つにまとめます。

const utf16 = text.length;
const codePoints = [ ...text ].length;
const graphemes = [ ...new Intl.Segmenter( 'ja', { granularity: 'grapheme' } ).segment( text ) ].length;
const bytes = new TextEncoder().encode( text ).length;

同じ見た目でも数え方は揃いません。

表示 string.length コードポイント 書記素クラスタ UTF-8
A 1 1 1 1
1 1 1 3
1 1 1 3
é(U+00E9) 1 1 1 2
(U+0065 + U+0301) 2 2 1 3
𩸽 2 1 1 4
👨‍👩‍👧‍👦 11 7 1 25

半角のAと全角のは別の番号です。家族の絵文字は人物と幅のない接合文字(ZWJ)をつないであり、表示では一つに見えます。

éは同じ見た目でも持ち方が二つあります。ここでいう正規化とは、同じ文字を1つの番号に合成するか、文字と記号に分けるかを揃えることです。この例ではNFCが合成した1コードポイント、NFDがeとアクセントを分けた2コードポイントです。text.normalize( 'NFC' )NFDへ揃えてから数えないと、同じ入力なのに上限判定がずれます。

入力欄の上限とカーソル

HTMLのmaxlengthはUTF-16の符号単位です。maxlength="10"の入力欄に家族の絵文字は入りません。入力欄のカーソル位置(selectionStart)も同じ符号単位で人がBackspaceで消すときはブラウザが書記素クラスタに寄せることが多いです。自分で末尾をslice(0, -1)するとサロゲートペアや絵文字の途中で切れることがあります。

見た目の一文字で上限を置きたい場合は書記素クラスタを数えます。IMEの変換中は値を書き換えません。変換確定後の処理ではカーソル位置とUndo履歴にも配慮します。送信先でも同じ単位で検証します。

Intl.Segmenterの書記素クラスタはおおむねUnicodeの分割規則です。タイ語のคำ(U+0E04 + U+0E33)はコードポイント2つで1クラスタになり声調記号や上下の母音記号を子音と一緒に消したいときはこの単位です。

一方で語と文の境界は言語処理が入ります。タイ語のように空白で語を分けない文章ではgranularity: 'word'と対象言語を渡します。境界判定は実装と辞書に依存するため環境によって結果の精度が変わります。

String Unitsのデモを開く
同じ文字列をUTF-16、コードポイント、書記素クラスタ、UTF-8の単位で比較する。

どうしても文章だと伝わりにくいのでこれらの違いを視覚的に示すデモを作成しました。

lang属性

ブラウザは要素のlangを見て、折り返しやハイフン、引用符の形を変えます。ページ全体の言語はhtmlへ付け、途中に別の言語がある場合はその要素にも付けます。html lang="ja"のまま英語の固有名詞を置くとハイフンや引用符は日本語側の規則のままです。

<html lang="ja">
	<p>参照は<span lang="en">Garden Eight</span></p>
</html>

読む方向はdirで指定します。アラビア語のように右から左へ進むページは根へdir="rtl"を付け、その中のラテン文字の名前や数字はdir="ltr"を残す必要があります。

<html lang="ar" dir="rtl">
	<p>راجع <span lang="en" dir="ltr">Garden Eight</span></p>
</html>

折り返しを指定するCSS

空白と折り返しにはwhite-spaceoverflow-wrapword-breakline-breakhyphenstext-wrapがあります。これらは同じ「折り返し」でも動かしている単位が違います。

overflow-wrapは収まらないときだけ途中で切るか、word-breakは普段から語のどこを切れ目にするかです。禁則、ハイフネーション、辞書改行もここで出ます。

プロパティ 役割 主な対象
white-space 空白と改行を畳むか、行を折り返すか 短いUI、本文、入力された文章
overflow-wrap 収まらないときだけ途中で切るか どの言語でも
word-break 普段からどこを切れ目にするか 欧文、CJK、東南アジア
line-break CJKの禁則をどこまで厳しくするか 主にCJK
hyphens 欧文でハイフン分割するか 欧文
text-wrap 折り返したあとの行の割り方 本文

white-space

white-spaceは空白と改行を保持するかに加え、自動的に折り返すかも決めます。

初期値のnormalは連続する空白を畳み、ソース内の改行を強制改行として残しません。

nowrapは空白と改行をnormalと同じように扱い自動折り返しだけを止め、ボタン、ナビゲーション、数値、1行の省略など途中で折り返したくない短い要素で使用します。

pre-wrapは連続する空白と改行を残したまま折り返します。CMSのテキスト欄やtextareaから受け取った改行を表示に残す場合に使用します。

.label {
	white-space: nowrap;
}

.user-text {
	white-space: pre-wrap;
}

break-spacesも空白と改行を残しますが、保持した空白の後ろを折り返し候補にし、行末の空白も幅を持ちます。普通の本文ではなく空白そのものを見せる必要がある表示に限って使用します。

overflow-wrap

行に収まらない長い語やURLを途中で切るかを決めます。

anywhereは収まらない場合に限り語の途中で折り返します。初期値のnormalでは長いURLが箱を突き抜けることがあります。

anywherebreak-wordはどちらも収まらない語を途中で切ります。異なるのは箱の最小幅の計算です。anywhereは途中の切れ目も最小幅(min-content)に入れます。break-wordは入れないのでFlexboxの子が親より広がりやすいです。

word-wrapoverflow-wrapの別名なので基本的には使用しません。CSS Textの日本語訳ではword-break: break-wordは非推奨で切る指定はoverflow-wrapです。

word-break

語のどこを切れ目にするかを決めます。初期値のnormal推奨です。

break-allは英単語の途中でも切るため英文では読みにくくなり、タイ語など空白で語を分けない文字も途中で切れます。keep-allはCJKの文字のあいだの折り返しを抑えますが日本語では句読点など限られた位置でしか折り返さず和文が突き抜けやすいです。タイ語では辞書による語境界の折り返しは残るので日本語と同じ突き抜け方にはなりません。

break-allkeep-allbodyへ置くと言語によって切れすぎるか切れなくなります。

line-break

line-breakCJKの句読点や記号の禁則をどこまで厳しくするかです。仕様上loose / normal / strictで必須の差が出るのはCJKでstrictは日本語では行頭に読点や小書きの仮名が来にくくなります。中国語や韓国語でも句読点、繰り返し記号、中点などの切れ方に影響する場合があります。英語の本文へ置いても長い英単語の切れ目はほとんど変わりません。

hyphens

hyphens: autoは欧文のハイフネーションで分割規則はlangに依存します。日本語の本文へ共通で足しても同じ役割にはなりません。

text-wrap

text-wrapは長い語を切る指定ではなく折り返したあとの行の割り方です。prettyは速度よりレイアウトの質を優先し複数行を見ながら切れ目を選ぶ指定です。短すぎる最終行を避けたり行長の差を抑えるなどの処理が考えられますが具体的な改善方法はブラウザに委ねられています。

空白で語を分けない言語

タイ語、ラオス語、クメール語は空白で語を分けません。lang="th"を付けword-breaknormalのままにします。CSS Textの日本語訳でも語の切れ目に辞書が必要だと書かれています。辞書が使用できない場合の仕様は文字と文字のあいだに折り返し候補を置くよう求めています。突き抜けは防げますが語の途中で切れます。仕様は対応が進んだブラウザがline-break: strictでタイ語の複合語を切りにくくする可能性にも触れています。ただし必須ではなく主たる改行は辞書です。

実装では次を分けて確認することを推奨します。

  • 収まらない長い語やURLをどこで切るか
  • 語の途中をどの言語で切ってよいか
  • 禁則、ハイフネーション、辞書改行をどの言語の本文にだけかけるか
Text Wrap CSSのデモを開く
日本語と英語を同じ幅に置き、折り返しのCSSとpaltを切り替えて比較する。

「Text Wrap CSS」では同じ幅の箱でoverflow-wrapword-breakline-breakhyphenspaltを切り替え、日本語と英語を並べています。

言語ごとの推奨

コピーして使用するときのメモです。本文に共通で置けるのは突き抜け防止とword-break: normalまでです。コード、識別子、ボタンなどは別に判断し禁則とハイフネーションはサイト全体へは重ねません。韓国語はword-break: normalのままにします。音節のあいだで折り返します。語の単位で折り返す場合はkeep-allです。

/* 共通 */
.prose {
	overflow-wrap: anywhere;
	word-break: normal;
}

/* 日本語 */
.prose:lang(ja),
.prose :lang(ja) {
	overflow-wrap: anywhere;
	word-break: normal;
	line-break: strict;
}

/* 中国語 */
.prose:lang(zh),
.prose :lang(zh) {
	overflow-wrap: anywhere;
	word-break: normal;
	line-break: strict;
}

/* 韓国語 */
.prose:lang(ko),
.prose :lang(ko) {
	overflow-wrap: anywhere;
	word-break: normal; /* 語の単位で折り返す場合は keep-all */
	line-break: strict;
}

/* タイ語 */
.prose:lang(th),
.prose :lang(th) {
	overflow-wrap: anywhere;
	word-break: normal;
}

/* 英語 */
.prose:lang(en),
.prose :lang(en) {
	overflow-wrap: anywhere;
	word-break: normal;
	hyphens: auto;
}

省略

よく使用するのは 1行のtext-overflow 複数行のline-clamp 文字数で切るJS の3つの方法です。

text-overflowline-clampは幅(と行数)から見切れたところに印を置きます。1行の省略は折り返しを止めてはみ出しを隠します。長いURLを途中で折り返す指定とは同時に使用できません。

.line {
	overflow: hidden;
	white-space: nowrap;
	text-overflow: ellipsis;
}

.clamp {
	overflow: hidden;
	display: -webkit-box;
	-webkit-box-orient: vertical;
	-webkit-line-clamp: 2;
}

text-overflowの初期値はclipです。はみ出したところで描画が止まり字形の途中で切れることがあります。ellipsisは仕様上、書記素クラスタを単位に隠して「…」を置きます。カスタム文字列や行の両端への指定もありますが、対応は環境に寄ります。

文字数の上限を判定するときはmaxlengthや検証処理を使用します。値を保存したりAPIへ送ったりする場合は、上限超過をエラーとして扱い、表示用の「…」を値へ足しません。

以下は表示用の省略です。三つの関数はlimitに省略記号を含めます。

見た目の文字数で切る場合はtruncateGraphemesを使用します。

function truncateGraphemes( text, limit, locale ) {
	const graphemes = [ ...new Intl.Segmenter( locale, { granularity: 'grapheme' } ).segment( text ) ];
	if ( graphemes.length <= limit ) {
		return text;
	}
	if ( limit <= 0 ) {
		return '';
	}
	if ( limit === 1 ) {
		return '…';
	}
	return graphemes.slice( 0, limit - 1 ).map( part => part.segment ).join( '' ) + '…';
}

UTF-16で数える別の表示と上限を合わせる場合はtruncateUtf16を使用します。サロゲートペアの途中に境界が来た場合は切れ目を1コードユニット戻します。書記素クラスタの途中で切れる可能性は残るため、見た目の文字数には使用しません。

function truncateUtf16( text, limit ) {
	if ( text.length <= limit ) {
		return text;
	}
	if ( limit <= 0 ) {
		return '';
	}
	if ( limit === 1 ) {
		return '…';
	}
	let end = limit - 1;
	const last = text.charCodeAt( end - 1 );
	const next = text.charCodeAt( end );
	if ( last >= 0xD800 && last <= 0xDBFF && next >= 0xDC00 && next <= 0xDFFF ) {
		end -= 1;
	}
	return text.slice( 0, end ) + '…';
}

ファイル名やURLの両端を見せる場合はtruncateMiddleです。

function truncateMiddle( text, limit, locale ) {
	const graphemes = [ ...new Intl.Segmenter( locale, { granularity: 'grapheme' } ).segment( text ) ];
	if ( graphemes.length <= limit ) {
		return text;
	}
	if ( limit <= 0 ) {
		return '';
	}
	if ( limit === 1 ) {
		return '…';
	}
	const end = Math.max( 1, Math.floor( ( limit - 1 ) / 2 ) );
	const start = limit - 1 - end;
	return graphemes.slice( 0, start ).map( part => part.segment ).join( '' ) + '…' + graphemes.slice( -end ).map( part => part.segment ).join( '' );
}
Text Overflowのデモを開く
同じ文字列を表示幅、行数、文字数の上限で省略して比較する。

「Text Overflow」は同じ文字列を表示幅、行数、文字数の上限で省略して比較したデモです。

字幅を変えるpalt

font-feature-settings: "palt"Proportional Alternate Widthsです。日本語向けのOpenTypeフォントに残る全角幅前提の字送りを字形はそのまま比例幅へ変更し、カーニングはfont-kerning: normalで足します。palt後の字幅向けに組んであるフォントがあるためです。

font-variant-east-asian: proportional-widthは別機能のpwidで比例幅の字形へ置き換えます。同時には使用しません。

また約物だけを半角にするならYaku Han JPfont-familyの先頭へ置く方法もあります。

文言を組み立てる単位

日本語では「残り」「3」「件」を並べても一つの文に見えます。英語だと数字の位置が変わり、1件のときはitem、2件からitemsです。ロシア語では1、2、5で名詞の形が分かれます。

new Intl.PluralRules( 'ja' ).select( 1 ); // other
new Intl.PluralRules( 'en' ).select( 1 ); // one
new Intl.PluralRules( 'en' ).select( 2 ); // other
new Intl.PluralRules( 'ru' ).select( 1 ); // one
new Intl.PluralRules( 'ru' ).select( 2 ); // few
new Intl.PluralRules( 'ru' ).select( 5 ); // many

onefewmanyは翻訳の文ではなくその言語で数に合わせて語を変えるグループです。

部品を翻訳してからつなぐとこの変化を扱えないため言語ごとの文を単位として持ち、数だけを差し込みます。

const copy = {
	ja: { other: '残り{n}件' },
	en: { one: '{n} item remaining', other: '{n} items remaining' }
};
const group = new Intl.PluralRules( locale ).select( n );
copy[ locale ][ group ].replace( '{n}', String( n ) );

太字やリンクも同じです。原文で強調した語を<span>や別ボタンに切り出すと翻訳後にその語が文の途中へ移ることがあります。CMSの入力欄も翻訳する人が文を並べ替えられるよう作成することが推奨されます。

参考