挿入したばかりのテキストを変更せずに置換マップを適用するには、元の文字列だけを検索し、別の結果を作成します。このガイドでは、Mac の Safari で JavaScript を使い、短い文章を文字どおりの予測可能な置換で更新します。

デスクトップでの編集作業で、blue を green に、green を gold に変更するとします。元の blue は一度だけ green になるべきです。元の green は gold になるべきです。次の方法なら、この違いに加えて、重複する語句、引用符で囲まれたテキスト、句読点を含む置換値も扱えます。

置換ループでテキストが二度変わるのはなぜですか?

各ルールを直前のルールの出力に適用するループでは、置換が連鎖します。この2つの色のルールでは、最初の処理で green が挿入されます。次の処理はその挿入された単語を見つけ、gold に変えてしまいます。ルールの順序を変えれば例によっては直せますが、どのマップでも確実に動作するとは限りません。

JavaScript の replace() は新しい文字列を返し、入力は変更しません。後続の呼び出しに返された文字列を渡すと、置換が連鎖します。そのため、明確なルールが必要です。すべての一致箇所を元の入力から探します。MDN の String.prototype.replace() のドキュメントを参照してください。

入力が The blue cup is beside the green cup. の場合、意図する結果は The green cup is beside the gold cup. です。元の色を表す単語は両方とも変わりますが、挿入された green はそのままです。以下の実装はこの変換を行います。

置換キーが重複する場合はどうすればよいですか?

置換の連鎖を防ぐには、複数のキーが同じ元のテキストに一致するとき、どれを優先するかも決める必要があります。この編集作業では、次の方針を明示的に採用します。これはこの例のための設計上の選択であり、JavaScript が自動的に適用するルールではありません。

  • 元の入力を左から右に走査します。
  • 各位置で、一致するキーのうち最も長いものを選びます。
  • 続行する前に、その元の一致箇所全体を消費します。
  • 対応する値は文字どおり挿入し、その値は検索しません。
  • 一致しないテキストはそのままコピーします。
  • 大文字・小文字と空白を厳密に照合します。

たとえば、blue と blue vase の両方がキーの場合、同じ位置から始まる blue vase が優先されます。後から始まる長い一致よりも、先に始まる一致が優先されます。元のテキスト範囲が一度消費されると、重複する一致にその範囲を再利用することはできません。

この方針は、少数の語句を文字どおり編集する場合に適しています。より具体的な語句が、それより短い接頭部分より優先されます。また、長さの異なるキーを入力した順序に出力が左右されなくなります。別の重複処理方針が必要な場合は、マップを適用する前に決めてください。

Mac でこの変換を実行するには?

Safari には JavaScript コンソールがあるため、この例の実行に別のアプリをインストールする必要はありません。Safari → 設定… → 詳細 → Web デベロッパ用の機能を表示 から Web インスペクタを有効にします。次に 開発 → Web インスペクタを表示 を選ぶか、Option-Command-I を押します。この手順はWebKit の Web インスペクタ設定ドキュメントに沿っています。

Safari のアドレス欄に about:blank と入力して空白のページを開き、インスペクタを開いて Console タブを選択します。以下のプログラム全体をコンソールの入力欄に貼り付け、実行します。WebKit のドキュメントによると、入力欄では JavaScript を評価し、その結果をコンソールに表示できます。Web インスペクタのインターフェイスを参照してください。

この例は、サンプルテキストを使った、インポートのない完全なブラウザプログラムです。外側の関数内に宣言を閉じ込めているため、サンプルを編集してからブロックを再度貼り付けて実行できます。

(() => {
  function applyReplacementMap(input, replacements) {
    if (typeof input !== "string" || !(replacements instanceof Map)) {
      throw new TypeError("Use a string and a Map.");
    }

    const entries = [...replacements];

    for (const [key, value] of entries) {
      if (
        typeof key !== "string" ||
        key.length === 0 ||
        typeof value !== "string"
      ) {
        throw new TypeError(
          "Keys must be nonempty strings; values must be strings."
        );
      }
    }

    entries.sort((a, b) => b[0].length - a[0].length);

    const output = [];
    let position = 0;

    while (position < input.length) {
      const entry = entries.find(([key]) =>
        input.startsWith(key, position)
      );

      if (entry) {
        output.push(entry[1]);
        position += entry[0].length;
      } else {
        output.push(input.slice(position, position + 1));
        position += 1;
      }
    }

    return output.join("");
  }

  const replacements = new Map([
    ["blue", "green"],
    ["green", "gold"],
    ["blue vase", "green bowl"],
    ["[tag]", "$&"],
    ["(draft)", ""]
  ]);

  const input =
    'The blue vase is beside the green cup. Label: "[tag]". Note: (draft)';

  const result = applyReplacementMap(input, replacements);
  console.log(result);
})();

出力されたテキストを以下に示します。Note: の後には末尾の空白が1つ残ります。ルールが削除するのは (draft) だけで、その前の空白は保持されるためです。

The green bowl is beside the gold cup. Label: "$&". Note: 

例を自分用に変更するには、input と new Map(...) 内のペアを編集します。各ペアには、正確な元の文字列とその置換文字列を指定します。キーごとに1つだけ登録してください。JavaScript のマップではキーが一意なため、同じキーを繰り返すと、別の競合ルールが追加されるのではなく値が更新されます。MDN の Map リファレンスを参照してください。

挿入したテキストが変更されない理由

position という名前のカーソルは input の中だけを進みます。一致判定には input.startsWith(key, position) を使います。これは指定した位置に文字列があるかを、大文字・小文字を区別して確認します。MDN の startsWith() リファレンスに説明があります。出力配列を検索対象として使うことはありません。

ルールが一致しない場合、slice(position, position + 1) は元の文字列の次の単位をコピーします。2番目のスライス位置は範囲に含まれず、スライスしても元の文字列は変更されません。これらの仕様により、関数は結果を別に作成しながら、一致しない内容を保持できます。MDN の slice() リファレンスを参照してください。

この例では、blue vase を green bowl に置き換えます。挿入された語句には別のキー green が含まれていますが、関数は元の語句を処理した時点で先へ進んでいるため、green bowl を調べることはありません。入力の後半にある元の green は、引き続き gold に変わります。

値を文字どおり挿入する方法と削除

この実装は置換値をそのまま追加します。そのため、$& は $& のままになり、キーに含まれる角括弧や丸括弧も通常の文字として扱われます。置換文字列を空にすると、一致した元の範囲が削除されます。この関数では正規表現も置換テンプレートの解釈も行いません。

空のキーは、元のテキストを消費しないため拒否します。これを許すと、一致した場合に処理を先へ進められなくなります。文字列以外の値も変換開始前に拒否するため、誤った値を黙って変換したり省いたりせず、問題を明らかにできます。空のマップを渡すと入力をそのまま返し、入力が空なら空文字列を返します。

コピーする前に結果を確認するには?

意図する動作を区別できる小さなケースを使ってください。以下の期待結果は、上記の実装から導かれます。特に記載がない限り、サンプルのマップを使います。

確認項目 入力 期待される結果
挿入したテキストが変わらない blue green green gold
長い語句が優先される blue vase green bowl
置換時の句読点がそのまま残る [tag] $&
大文字・小文字を区別する Blue Blue
部分文字列も照合する blueprint greenprint
引用符が残る "blue" "green"
空のマップ blue blue

置換関数とこれらの動作は、Node.js 20.19.2 で確認しました。削除、空の入力、重複する一致、無効なマップの拒否もあわせて確認しています。これはその実行環境での JavaScript 変換を検証したもので、Safari の操作手順は WebKit のドキュメントに基づいています。

特に部分文字列の確認に注意してください。この関数は単語の境界を推測したり、文法を修正したりしません。キーは長い単語の途中にも一致し、テキストを削除すると余分な空白が残ることがあります。語句を編集する場合は、周囲の文字を確認し、意図した変更に必要な範囲でキーを具体的にしてください。

また、1回の適用と繰り返しの適用は区別してください。この関数を元のテキストに一度だけ使えば、その実行中の置換の連鎖を防げます。結果を関数に再度渡すと、新たな変換が始まり、前に挿入された単語も一致する可能性があります。編集をやり直すときは、元の input を入力として使ってください。

JavaScript の replace 呼び出し1回で同じことができますか?

できます。少数の固定された単純なキーであれば、グローバル正規表現と置換コールバックの組み合わせで、同じように置換の連鎖を防げます。ECMAScript 仕様では、元の文字列から一致箇所を集めた後、置換結果を構築します。規定された正規表現による置換アルゴリズムを参照してください。

次の独立した完全な例は、固定された2つの色のキーを処理します。

(() => {
  const replacements = new Map([
    ["blue", "green"],
    ["green", "gold"]
  ]);

  const input = "The blue cup is beside the green cup.";
  const result = input.replace(
    /blue|green/g,
    (word) => replacements.get(word)
  );

  console.log(result);
  // The green cup is beside the gold cup.
})();

g フラグを付けると、式がすべての一致を置き換えます。また、コールバックから値を返すことで、置換文字列では一致した部分文字列を表す $& などのパターンとして解釈されるのを防げます。MDN は、replace() リファレンスで両方の動作を明示しています。

句読点を含む可変のマップに対して正規表現を構築する場合は、追加の対応が必要です。キーをリテラルのパターンとして使えるようにエスケープし、重複する選択肢には明確な優先順位を設定します。JavaScript にはパターンをリテラルとして構築する RegExp.escape() がありますが、古いブラウザでは利用できない場合があると MDN は説明しています。MDN の RegExp.escape() のドキュメントを参照してください。

ここで扱うデスクトップでの語句編集では、リテラルスキャナーによって判断の内容が明確になります。元のテキストを読み、各位置で最も長いキーを選び、各置換文字列をそのまま追加します。元の文章に対して実行し、結果の語句と空白を確認してから、完成したテキストを文書にコピーしてください。

出典