Reactで、文章の中の表記ゆれを見つけてそろえる方法

​​React​JavaScript,Playwright,TypeScript,Unicode

文章の中のカタカナ語を取り出し、半角カナを全角にする、「ヴァ」を「バ」にする、語の終わりの「ー」を除く、という3つの処理で比べるための形を作ります。比べるための形が同じなのに書き方が2つ以上ある語が、「サーバー」と「サーバ」のような表記ゆれです。「下さい」と「ください」のようにカタカナ語ではないものは、同じ意味の書き方の組を辞書にしておいて探します。

文章の中の見つけた語は <mark> で囲んで背景に色を付け、「サーバー(1回)にそろえる」のようなボタンで、ほかの書き方をまとめて置き換えます。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。関数の結果は Node.js で確かめ、tsc で型エラーがないことを確かめました。コンポーネントは StrictMode で包んだ開発用のビルドを、幅 375px の Chromium で Playwright から操作しました。日本語入力(IME)で打ったときの動き、Firefox と Safari、スマートフォンの実機、長い文章での処理時間は試していません。

幅375pxの画面で撮ったコンポーネント。「調べる文章」の欄の下に水色の文字で「表記ゆれが3組あります」と出て、その下に「サーバー(1回)にそろえる」「サーバ(1回)にそろえる」など6つのボタンが、左に水色の線を引いた3つの組に分かれて並んでいる。いちばん下には文章が表示され、「サーバー」「サーバ」「ユーザー」「ユーザー」「下さい」「ください」の背景が薄いピンクになっている
見本の文章で見つかった3組の表記ゆれ。組ごとにボタンが並び、文章の中の該当する語は背景がピンクになる

カタカナ語を取り出して、比べるための形にそろえる

カタカナ語の表記ゆれを探す部分は次のとおりです。

ts
// 文章の中の表記ゆれを見つけて、1つの書き方にそろえる

export type Variant = { form: string; count: number };
export type VariantGroup = {
  kind: 'katakana' | 'dictionary';
  key: string;
  variants: Variant[]; // 多く出てくる書き方から順に並べる
};

// カタカナの語。ァ〜ヺ、長音の ー、繰り返し記号の ヽ ヾ と、半角カナ(ヲ〜゚)が続く部分
const KATAKANA_WORD = /[ァ-ヺー-ヾヲ-゚]+/g;

const VU: Record<string, string> = { ヴァ: 'バ', ヴィ: 'ビ', ヴェ: 'ベ', ヴォ: 'ボ', ヴ: 'ブ' };

// 書き方の違いを消した「比べるための形」を作る
export function katakanaKey(word: string): string {
  return word
    .normalize('NFKC') // 半角カナを全角にする(サーバ → サーバ)
    .replace(/ヴ[ァィェォ]?/g, (s) => VU[s]) // ヴァイオリン → バイオリン
    .replace(/ー+$/, ''); // 語の終わりの長音を除く(サーバー → サーバ)
}

// 比べるための形が同じで、書き方が2つ以上ある語を集める
export function findKatakanaVariants(text: string): VariantGroup[] {
  const byKey = new Map<string, Map<string, number>>();
  for (const [word] of text.matchAll(KATAKANA_WORD)) {
    const key = katakanaKey(word);
    if (key.length < 2) continue; // 「ア」のような1文字の語は比べない
    const forms = byKey.get(key) ?? new Map<string, number>();
    forms.set(word, (forms.get(word) ?? 0) + 1);
    byKey.set(key, forms);
  }
  const groups: VariantGroup[] = [];
  for (const [key, forms] of byKey) {
    if (forms.size < 2) continue;
    const variants = [...forms].map(([form, count]) => ({ form, count }));
    groups.push({ kind: 'katakana', key, variants: variants.sort((a, b) => b.count - a.count) });
  }
  return groups;
}

KATAKANA_WORD は、カタカナ・長音の「ー」・半角カナが続く部分を1つの語として取り出す正規表現です。MDN の matchAll() の説明にあるとおり、matchAll() に渡す正規表現には g フラグが要ります。付けないと TypeError になります。

katakanaKey() では、最初に normalize('NFKC') をかけます。MDN の normalize() の説明にあるとおり、NFKC は互換等価な文字を同じ形にそろえる正規化で、半角カナは全角のカナになります。そのあと「ヴァ」などを「バ」などに置き換え、語の終わりの「ー」を除きます。検証環境の Node.js では、次のようになりました。

語 katakanaKey() の結果
サーバー / サーバ / サーバー サーバ
ヴァイオリン / バイオリン バイオリン
ヴィンテージ ビンテージ
コンピューター コンピュータ

findKatakanaVariants() は、この形をキーにした Map で、元の書き方ごとに回数を数えます。書き方が2つ以上あるキーだけを返し、書き方は回数の多い順に並べます。「ア」と「アー」のように、比べるための形が1文字になる語は比べません。検証環境で findVariants('アとアー') を実行すると、空の配列でした。

見本の文章(「サーバーを再起動したあと、サーバの設定を確かめます。」「ユーザーの一覧は、ユーザー画面から開いて下さい。」「全ての設定を保存したら、画面を閉じてください。」の3行)では、「サーバー」と「サーバ」、「ユーザー」と「ユーザー」の2組が見つかりました。辞書で見つかる「下さい」と「ください」と合わせて、3組です。

語の途中の長音は比べない

katakanaKey() で除くのは、語の終わりの「ー」だけです。そのため、「サーバーサイド」と「サーバサイド」は表記ゆれとして見つかりません。検証環境で findVariants('サーバーサイドとサーバサイド') を実行すると、空の配列でした。

語の途中の「ー」まで除けば、この2つの比べるための形は、どちらも「サバサイド」になります。ただし、「ビール」と「ビル」もどちらも「ビル」になり、別の言葉を同じ語として扱ってしまいます。そこで、この記事のコードでは語の終わりだけにしています。

中黒の「・」は KATAKANA_WORD に入れていないので、語の区切りになります。「ユーザー・インターフェース」は「ユーザー」と「インターフェース」の2語に分かれるので、「ユーザインターフェース」とは比べられません。検証環境でも、この2つを並べた文章からは何も見つかりませんでした。複合語の表記ゆれは、次の節で作る辞書に組を足して探します。

カタカナ語以外は、辞書の組で探す

「全て」と「すべて」のような表記ゆれは、文字の種類が違うので、比べるための形を作れません。同じ意味の書き方を組にした辞書を用意し、文章の中に2つ以上の書き方が出てくる組を返します。

ts
// カタカナ以外の表記ゆれは、同じ意味の書き方の組を辞書にしておく
export const DICTIONARY: string[][] = [
  ['すべて', '全て'],
  ['ください', '下さい'],
  ['子ども', '子供'],
];

function countOf(text: string, form: string): number {
  return text.split(form).length - 1;
}

export function findDictionaryVariants(text: string, dictionary = DICTIONARY): VariantGroup[] {
  const groups: VariantGroup[] = [];
  for (const forms of dictionary) {
    const variants = forms
      .map((form) => ({ form, count: countOf(text, form) }))
      .filter((v) => v.count > 0);
    if (variants.length < 2) continue;
    groups.push({ kind: 'dictionary', key: forms[0], variants: variants.sort((a, b) => b.count - a.count) });
  }
  return groups;
}

export function findVariants(text: string): VariantGroup[] {
  return [...findKatakanaVariants(text), ...findDictionaryVariants(text)];
}

辞書の語は、文字列としてそのまま探します。そのため、短い語を辞書に入れると、別の言葉の一部を拾います。試しに ['など', '等'] の組を足して「男女平等など、等しく扱う」を調べると、「等」が2回と数えられました。「平等」と「等しく」の中の「等」です。辞書には、ほかの言葉の一部になりにくい語を入れます。

ほかの書き方を、語の単位で置き換える

ボタンを押したときに呼ぶ unify() です。

ts
// group の書き方を、すべて to にそろえる
export function unify(text: string, group: VariantGroup, to: string): string {
  const others = group.variants.map((v) => v.form).filter((f) => f !== to);
  if (group.kind === 'katakana') {
    // カタカナの語の単位で置き換えるので、「サーバー」の中の「サーバ」は置き換えない
    return text.replace(KATAKANA_WORD, (w) => (others.includes(w) ? to : w));
  }
  return others.reduce((t, f) => t.split(f).join(to), text);
}

「サーバ」を「サーバー」に置き換えるときに、文字列の replaceAll('サーバ', 'サーバー') を使うと、もとから「サーバー」だった部分の「サーバ」も置き換わります。見本の文章で試したところ、1行目の先頭が「サーバーーを再起動したあと」になりました。

unify() では、カタカナ語の組は KATAKANA_WORD で語ごとに取り出し、語の全体がほかの書き方と同じときだけ置き換えます。見本の文章で「サーバー」にそろえると、「サーバーを再起動したあと、サーバーの設定を確かめます。」になり、長音が重なることはありませんでした。辞書の組は split() と join() で置き換えます。

見つけた語を <mark> で囲む

文章を表示するときに、表記ゆれの語だけを <mark> で囲めるように、文章を区切る関数です。

ts
export type Segment = { text: string; mark: boolean };

const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');

// 表記ゆれの語を <mark> で囲んで表示するために、文章を区切る
export function highlight(text: string, groups: VariantGroup[]): Segment[] {
  const katakana = new Set<string>();
  const dictionary: string[] = [];
  for (const g of groups) {
    for (const v of g.variants) {
      if (g.kind === 'katakana') katakana.add(v.form);
      else dictionary.push(v.form);
    }
  }
  const pattern = [KATAKANA_WORD.source, ...dictionary.map(escape)].join('|');
  const segments: Segment[] = [];
  let last = 0;
  for (const m of text.matchAll(new RegExp(pattern, 'g'))) {
    const word = m[0];
    if (!katakana.has(word) && !dictionary.includes(word)) continue;
    if (m.index > last) segments.push({ text: text.slice(last, m.index), mark: false });
    segments.push({ text: word, mark: true });
    last = m.index + word.length;
  }
  if (last < text.length) segments.push({ text: text.slice(last), mark: false });
  return segments;
}

カタカナ語の正規表現と辞書の語を | でつないだ1つの正規表現で、文章を先頭から調べます。カタカナ語は語の全体で比べるので、「サーバー」の中の「サーバ」だけが囲まれることはありません。辞書の語は、正規表現の中で特別な意味を持つ記号を escape() でエスケープしてからつなぎます。

一覧とボタンを出すコンポーネント

コンポーネントは次のとおりです。state は文章の1つだけで、表記ゆれの一覧と区切った文章は、文章から useMemo で計算します。

tsx
import { useId, useMemo, useState } from 'react';
import { findVariants, highlight, unify } from './variants';

export function VariantChecker({ initial = '' }: { initial?: string }) {
  const id = useId();
  const [text, setText] = useState(initial);
  const groups = useMemo(() => findVariants(text), [text]);
  const segments = useMemo(() => highlight(text, groups), [text, groups]);

  return (
    <div className="variant-checker">
      <label htmlFor={id}>調べる文章</label>
      <textarea id={id} rows={6} value={text} onChange={(e) => setText(e.target.value)} />
      <p role="status">
        {groups.length === 0 ? '表記ゆれは見つかりませんでした' : `表記ゆれが${groups.length}組あります`}
      </p>
      <ul className="groups">
        {groups.map((g) => (
          <li key={`${g.kind}:${g.key}`}>
            {g.variants.map((v) => (
              <button key={v.form} type="button" onClick={() => setText(unify(text, g, v.form))}>
                {v.form}({v.count}回)にそろえる
              </button>
            ))}
          </li>
        ))}
      </ul>
      <p className="preview">
        {segments.map((s, i) => (s.mark ? <mark key={i}>{s.text}</mark> : s.text))}
      </p>
    </div>
  );
}
css
.variant-checker { display: grid; gap: 8px; max-width: 480px; }
.variant-checker textarea { font-size: 16px; }
.variant-checker [role='status'] { margin: 0; color: #067a98; }
.variant-checker .groups { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
.variant-checker .groups li { display: flex; flex-wrap: wrap; gap: 8px; padding-left: 8px; border-left: 4px solid #00c8e8; }
.variant-checker .preview { margin: 0; white-space: pre-wrap; line-height: 1.8; }
.variant-checker mark { background: #ffe0e8; color: inherit; }

見本の文章を初期値にして、Playwright で操作しました。最初は「表記ゆれが3組あります」と出て、「サーバー」「サーバ」「ユーザー」「ユーザー」「下さい」「ください」の6か所が <mark> で囲まれました。「全て」は、文章の中に「すべて」がないので、表記ゆれになりません。

「サーバー(1回)にそろえる」を押すと、1行目の「サーバの設定」の「サーバ」が「サーバー」になり、表示は「表記ゆれが2組あります」に変わりました。続けて「ユーザー(1回)にそろえる」「ください(1回)にそろえる」を押すと、「表記ゆれは見つかりませんでした」になり、<mark> で囲まれた語はなくなりました。そのあと入力欄に「すべて」と打ち足すと、「全て」との組ができて「表記ゆれが1組あります」に戻りました。

幅 375px の画面で、横にはみ出す要素はありませんでした(scrollWidth は 375)。ブラウザのコンソールに、エラーと警告は出ませんでした。

​Posted by 86