Intl.segmenter + React で改行位置の制御
ブラウザに文章を表示させる際、欧文であれば適当な単語間のスペースで改行がなされますが、日本語は単語の途中で改行されることがあります。組版規則上はそれでも問題ありませんが、見た目を重視して単語の区切りで改行する実装を考えます。
wbr 要素と改行位置の候補指定
改行可能な位置を提示するには、wbr 要素を改行候補位置に挿入した上で、親要素のスタイルとして word-break: keep-all
をセットします。word-break
はブラウザによる改行挿入の可否を制御するプロパティで、keep-all
を指定した場合は、CJK(Chinese, Japanese, Korean)言語における文中での自動改行が制限されます。
「あなたと Java、今すぐダウンロード」に wbr 要素を挿入する場合は、以下の指定が考えられます[1]。
<span style="word-break: keep-all;">
あなたと Java、今すぐ<wbr/>ダウンロード
</span>
キャッチコピー程度の長さであれば手動指定で対処できますが、長文に亘る場合は自動で改行候補を推定したいものです。
Intl.segmenter
Chrome 87 から実装されている Intl.segmenter は、ロケールに応じたテキストの分割情報を取得する API です。第 2 引数 options
に granularity: word
を渡すと単語毎の区切りとなります。
const sentence =
"Intl.Segmenter オブジェクトは、ロケールに応じたテキストのセグメンテーションを可能にし、文字列から意味のある項目(書記素、単語、文)を取得することができます。";
const segmenter = new Intl.Segmenter("ja-JP", { granularity: "word" });
const result = Array.from(segmenter.segment(sentence)).map(
(segment) => segment.segment
);
/* ['Intl', '.', 'Segmenter', ' ', 'オブジェクト', 'は', '、',
'ロ', 'ケール', 'に', '応', 'じ', 'た', 'テキスト', 'の',
'セグメンテーション', 'を', '可能', 'にし', '、', '文字', '列', 'から',
'意味', 'の', 'ある', '項目', '(', '書記', '素', '、', '単語', '、',
'文', ')', 'を', '取得', 'する', 'こと', 'が', 'でき', 'ます', '。'] */
「ロ/ケール」「応/じ/た」等、何点か気になる箇所[2]はありますが、細かいところに目を瞑れば概ね及第点かと思います。
Next.js 上で Intl.Segmenter を利用するコードを以下に示します。得られたセグメント間に wbr タグを挿入することで、人力を介することなく改行候補の推定が可能となりました。
import { useState } from "react";
const Index = () => {
const [segments, setSegments] = useState<string[]>([]);
const sentence =
"Intl.Segmenter オブジェクトは、ロケールに応じたテキストの「セグメンテーション」を可能にし、文字列から意味のある項目(書記素、単語、文)を取得することができます。";
const canInsertWbr = (segment: string, index: number, array: string[]) => {
const notHeads = new Set(["。", "、", ".", ",", "!", "?", "」", ")", "】"]);
const notTails = new Set(["「", "(", "【"]);
return (
index < array.length - 1 &&
!notHeads.has(array[index + 1]) &&
!notTails.has(segment)
);
};
useEffect(() => {
const segmenter = new Intl.Segmenter("ja-JP", { granularity: "word" });
setSegments(Array.from(segmenter.segment(sentence)).map((segment) => segment.segment));
}, []);
return (
<span style={{ wordBreak: "keep-all" }}>
{segments.map((segment, index, array) => (
<>
{segment}
{canInsertWbr(segment, index, array) && <wbr />}
</>
))}
</span>
)
};
export default Index;
処理に際しては、以下の点に留意する必要があります。
- Hydration Failed を回避するために、分割処理は useEffect 内に記述し、クライアントサイドでのみ実行されるようにする
- 単にセグメント間に wbr 要素を挿入するだけでは禁則処理が解除されてしまうため、現在のセグメントが行末禁則文字(
「(【
等)、あるいは後続するセグメントが行頭禁則文字(。、」)】
等)の場合は wbr 要素を挿入しない
子要素も再帰的に分割する
先述したソースコードはテキストノードの分割を対象としていました。続いて、以下のように子要素とテキストノードが混在する処理を考えます。
<p>
<b><a href="...">Intl.Segmenter</a> オブジェクト</b>
は、ロケールに応じたテキストのセグメンテーションを可能にし、文字列から意味のある項目(
<b>書記素、単語、文</b>)を取得することができます。
</p>
props に存在する children
の型である React.ReactNode
は、下記の型エイリアスによって定義されます。children
は React 要素の他に、string, number, undefined 等の値を取りうることが読み取れます。
type ReactNode =
ReactChild | ReactFragment | ReactPortal | boolean | null | undefined;
type ReactChild = ReactElement | ReactText;
type ReactText = string | number;
したがって、children が string の場合は Intl.Segmenter による分割処理を行い、それ以外の場合は再帰的に処理を行えば良さそうです。children(あるいは children.props.children)が配列であった場合は、map で反復処理を行い、その間にも wbr 要素を挿入します。
これらの処理を Segmenter.tsx
としてコンポーネント化したコードを以下に示します。
Segmenter.tsx
import React, { useEffect, useState } from "react";
interface SegmenterProps {
children: React.ReactNode;
}
const Segmenter = ({ children }: SegmenterProps): JSX.Element => {
const [isClient, setClient] = useState(false);
const processArray = (array: any[]) => (
<>
{array.map((child, index, array) => (
<>
<InnerSegmenter key={index}>{child}</InnerSegmenter>
<wbr />
</>
))}
</>
);
const canInsertWbr = (segment: string, index: number, array: string[]) => {
const notHeads = new Set(["。", "、", ".", ",", "!", "?", "」", ")", "】"]);
const notTails = new Set(["「", "(", "【"]);
return (
index < array.length - 1 &&
!notHeads.has(array[index + 1]) &&
!notTails.has(segment)
);
};
const InnerSegmenter = ({ children }: SegmenterProps): JSX.Element => {
if (!isClient || !children) {
return <>{children}</>;
}
if (typeof children === "string") {
const segmenter = new Intl.Segmenter("ja-JP", { granularity: "word" });
const segments = Array.from(segmenter.segment(children)).map(
(segment) => segment.segment
);
return (
<>
{segments.map((segment, index, array) => (
<React.Fragment key={index}>
{segment}
{canInsertWbr(segment, index, array) && <wbr />}
</React.Fragment>
))}
</>
);
}
if (React.isValidElement(children)) {
const descendants = Array.isArray(children.props.children) ? (
processArray(children.props.children)
) : (
<InnerSegmenter>{children.props.children}</InnerSegmenter>
);
return React.cloneElement(children, children.props, descendants);
}
if (Array.isArray(children)) {
return processArray(children);
}
return <>{children}</>;
};
useEffect(() => {
setClient(true);
}, []);
return (
<span style={{ wordBreak: "keep-all" }}>
<InnerSegmenter>{children}</InnerSegmenter>
</span>
);
};
export default Segmenter;
あとは、単語間での改行を行いたい箇所で Segmenter コンポーネントを呼び出すだけで処理が行われます。
import Segmenter from "./Segmenter";
<p>
<Segmenter>
<b><a href="...">Intl.Segmenter</a> オブジェクト</b>
は、ロケールに応じたテキストのセグメンテーションを可能にし、文字列から意味のある項目(
<b>書記素、単語、文</b>)を取得することができます。
</Segmenter>
</p>
Discussion