家計簿アプリを作る #83:検索の全角・半角統一

家計簿アプリ作成シリーズの第83回です。以前(お題63)のハイライト機能実装時に「全角/半角の違いまでは対応しない」と割り切っていた部分に、今回対応します。

最初の実装:normalize('NFKC')で全角/半角を統一

JavaScript標準の String.prototype.normalize('NFKC') を使えば、全角英数字を含む多くの互換文字を半角に正規化できます。

function normalizeForSearch(str: string): string {
  return str.normalize('NFKC').toLowerCase();
}

検索条件の比較(+page.server.ts)はこれでうまくいきましたが、ハイライト表示側で問題が発生しました。

ハマりポイント①:表示テキストまで正規化してしまう

最初、ハイライト対象のテキスト自体を正規化してから highlightParts に渡していました。

// ❌ 表示するテキスト自体まで正規化されてしまう
highlightParts(normalizeForSearch(transaction.memo), normalizeForSearch(data.searchQuery))

text.split(...) は渡された文字列そのものを分割するだけなので、渡す時点で正規化してしまうと元の表記が失われ、半角のメモが全角検索でヒットしても、画面には正規化後の(意図しない)表記で表示されてしまいました。

マッチ判定は正規化した文字列で行い、実際に表示する部分は元のテキストから切り出す、という役割分担が必要でした。

ハマりポイント②:NFKCがカタカナも正規化してしまう

highlightParts を、正規化した文字列でマッチ位置を探し、そのインデックスを使って元のテキストから slice する形に書き直しました。

function highlightParts(text: string, keyword: string) {
  if (!keyword) return [{ text, match: false }];
  const normalizedText = normalizeForSearch(text);
  const normalizedKeyword = normalizeForSearch(keyword);
  const regex = new RegExp(escapeRegExp(normalizedKeyword), "gi");

  const parts = [];
  let lastIndex = 0;
  let match;
  while ((match = regex.exec(normalizedText)) !== null) {
    if (match.index > lastIndex) {
      parts.push({ text: text.slice(lastIndex, match.index), match: false });
    }
    parts.push({ text: text.slice(match.index, match.index + match[0].length), match: true });
    lastIndex = match.index + match[0].length;
  }
  if (lastIndex < text.length) {
    parts.push({ text: text.slice(lastIndex), match: false });
  }
  return parts;
}

これでも、半角カタカナを含むメモでハイライトが1文字ズレる不具合が発生しました。原因は normalize('NFKC') が全角/半角の英数字だけでなく半角カタカナにも作用することでした。濁点付きの半角カタカナ(半角カナ+半角濁点の2文字構成)がNFKC正規化によって全角カタカナ1文字に合成され、正規化後の文字列の長さが元の文字列より短くなることがあり、これが「正規化後のインデックス」と「元の文字列上の実際の位置」のズレを引き起こしていました。

今回の要件は「全角/半角の英数字を統一したい」というものだけだったので、normalize('NFKC') という広い変換ではなく、全角英数字だけをピンポイントで変換する関数に絞ることで解決しました。

// ✅ 全角英数字だけを対象にし、文字数が変わらないようにする
function normalizeForSearch(str: string): string {
  return str
    .replace(/[A-Za-z0-9]/g, (ch) =>
      String.fromCharCode(ch.charCodeAt(0) - 0xfee0)
    )
    .toLowerCase();
}

0xFEE0 は全角英数字と半角英数字の間の固定オフセットです。カタカナやひらがな、漢字には一切触れないので、文字数が変わる心配がありません。

escapeRegExpのおさらい

ユーザー入力をそのまま正規表現に組み込む前に、特殊文字をエスケープする定石も改めて使いました。

function escapeRegExp(str: string): string {
  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

$& は置換における特殊変数で「マッチした文字列そのもの」を意味します。"1+1" のような入力があった場合、+(正規表現のメタ文字)の直前にバックスラッシュを挿入して "1\+1" にすることで、正規表現として使っても「ただの + という文字」として扱われるようにしています。

重複ロジックの共通化

normalizeForSearch が +page.server.ts と +page.svelte の2箇所で同じ定義になっていたので、$lib/utils/search.ts に切り出しました。DBやAPIキーに触れない純粋な関数なので、$lib/server ではなく $lib/utils が適切な置き場所でした。

まとめ

ポイント 内容
マッチ判定と表示の分離 正規化した文字列で位置を探し、切り出しは元の文字列から行う
normalize('NFKC')の副作用 全角/半角の英数字だけでなく、半角カタカナの合成にも作用し文字数が変わることがある
範囲を絞った正規化 目的が「全角/半角英数字の統一」なら、その範囲だけをピンポイントで変換する方が安全
RegExpコンストラクタ パターンを変数から動的に組み立てたい場合に使う。exec()はgフラグと組み合わせて全マッチを順に取得できる
重複ロジックの共通化 複数箇所に同じ関数を書かず、$lib/utilsに集約する
← トップページに戻る