🖋️

Intl.segmenter + React で改行位置の制御

2023/04/30に公開

ブラウザに文章を表示させる際、欧文であれば適当な単語間のスペースで改行がなされますが、日本語は単語の途中で改行されることがあります。組版規則上はそれでも問題ありませんが、見た目を重視して単語の区切りで改行する実装を考えます。

あなたと Java、今すぐダウンロー ド

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 引数 optionsgranularity: word を渡すと単語毎の区切りとなります。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Intl/Segmenter

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 タグを挿入することで、人力を介することなく改行候補の推定が可能となりました。

Index.tsx
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;

Next.js での実行結果

処理に際しては、以下の点に留意する必要があります。

  • 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
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>

Segmenter コンポーネントの実行結果

脚注
  1. Java の前には半角スペースが挿入されているため、wbr 要素を挿入しなくても改行可能です。読点(、)の後ろも同様です。 ↩︎

  2. 正しくは「ロケール」「応じ/た」 ↩︎

Discussion