1. 何が問題だったか
WordPress のブロックエディタに LaTeX を貼り付けると MathML に変換するプラグインを個人で開発しています。
今回、inline LaTeX の判定ロジックを根本から見直したので、その考え方を書き残しておきます。
貼り付けた数式が変換されないバグが断続的に起きていました。
ログを確認すると、$(ax+by, cx+dy)$ や $(ea+fc)x + (eb+fd)y$ のような、数学的には普通の式が「inline LaTeX ではない」と判定されて素通りしていました。
原因は判定ロジックの構造にありました。
従来の実装は、通過させたいパターンを個別の正規表現で列挙する方式でした。
if (SHORT_FUNCTION_PATTERN.test(text)) return true;
if (SHORT_VARIABLE_PATTERN.test(text)) return true;
if (NUMBER_PATTERN.test(text)) return true;
if (ABSOLUTE_VALUE_PATTERN.test(text)) return true;
if (SIMPLE_IDENTIFIER_PATTERN.test(text)) return true;Code language: JavaScript (javascript)
新しいバグが出るたびにパターンを追加するので、コードは増えるが網羅性は上がりません。$(ax+by, cx+dy)$ はどのパターンにも当てはまらないから未検出になる——これが典型的な失敗でした。
2. 発想の転換
個別パターンを「どれかに当てはまれば通す」という構造から、「数式らしくないものを弾く」という構造へ切り替えました。
短い inline LaTeX の判定に必要な条件を整理すると、次の 6 つに収まります。
- delimiter が正しく閉じている(改行なし)
- 長さが上限(24文字)以内
- 許容文字集合に収まっている
- 括弧の整合が取れている
- 自然言語の文章ではない
- 数式構造を示す記号またはカンマを含む
この条件を通過したものは inline LaTeX として扱います。
個別のパターンは、この汎化条件で拾えない特定の形、たとえば |x| のような絶対値記号にだけ残しています。
function looksLikeShortInlineMath(text) {
if (!SHORT_MATH_ALLOWED_CHAR_PATTERN.test(text)) return false;
if (!hasBalancedShortMathDelimiters(text)) return false;
if (SHORT_FUNCTION_PATTERN.test(text)) return true;
// ... 残った個別パターン
if (looksLikeNaturalLanguage(text)) return false;
return MATH_STRUCTURE_PATTERN.test(text) || text.includes(',');
}Code language: JavaScript (javascript)
最後の行が核心で、「数学的な記号かカンマを含む短い文字列は数式と見なす」という汎化ルールになっています。$(ax+by, cx+dy)$ はカンマを含むので通り、$(ea+fc)x + (eb+fd)y$ は + を含むので通ります。
3. 自然言語との誤検出をどう防ぐか
汎化するとゴミが増えるのでは、という懸念は正当です。
誤検出の主な候補は「アルファベットだけで構成された短い英単語の並び」になります。
looksLikeNaturalLanguage は、単語が 3 つ以上並んでいて、すべてがアルファベット文字列のときだけ true を返します。
function looksLikeNaturalLanguage(text) {
var words = text.trim().split(/\s+/).filter(Boolean);
if (words.length < 3) return false;
return words.every(w => NATURAL_LANGUAGE_WORD_PATTERN.test(w));
}Code language: JavaScript (javascript)
ax + b は 3 語ありますが + がアルファベットではないので通過します。the cat sat は 3 語すべてアルファベットなので弾かれます。
この判定で誤検出はほぼ防げています。
4. Markdown 経路での別問題
inline LaTeX 判定とは別に、Markdown を含む貼り付けで \(...\) が消える問題もありました。
クリップボードの text/plain を見ると \(...\) は残っているのに、Gutenberg の pasteHandler に渡した後で delimiter が壊れていました。
Markdown のパーサーが \( と \) をエスケープシーケンスとして消費していたためです。
対応はシンプルで、pasteHandler に渡す直前だけプレースホルダに置き換え、後処理で元に戻します。
変換パイプラインの特定の区間だけ文字列を保護する、という考え方です。
const PLACEHOLDER_OPEN = '\x02LPAREN\x03';
const PLACEHOLDER_CLOSE = '\x02RPAREN\x03';
text = text
.replace(/\\\(/g, PLACEHOLDER_OPEN)
.replace(/\\\)/g, PLACEHOLDER_CLOSE);
// pasteHandler に渡す
text = text
.replace(new RegExp(PLACEHOLDER_OPEN, 'g'), '\\(')
.replace(new RegExp(PLACEHOLDER_CLOSE, 'g'), '\\)');Code language: JavaScript (javascript)
5. table block での別の落とし穴
paragraph や list では変換できるのに table セルだけ変換されない、という現象もありました。
ログで core/table の block shape を観測したところ、テストで想定していたデータ構造と実機が違っていました。
テストでは [[cell, cell], ...] の二重配列を想定していましたが、実機では { cells: [cell, ...] } という row オブジェクトの配列でした。
convertTableCells() が Array.isArray(row) を前提にしていたため、row オブジェクトを受け取った時点でセルまで降りられず終わっていたわけです。
自分が想定した構造と、実際にメモリ上にある構造が一致している保証はありません。
実機の shape を観測してから走査処理を書く、というのがここで得た教訓です。
6. 設計変更の考え方
個別ケースを列挙して対応するアプローチは、失敗ケースが増えるたびにコードが膨らみます。
「何を弾くか」を条件で表現するアプローチは、コードの量を増やさずに網羅性が上がります。
ただし汎化ルールはトレードオフを持ちます。
「数式構造を含む短い文字列」という条件は緩いので、自然言語誤検出の対策が別途必要になりました。
設計変更のたびに「何を犠牲にしているか」を意識しないと、問題を別の場所に移動させるだけになります。
デバッグログを管理画面から確認できる仕組みを最初から作っておいたことも、今回の修正を速めました。
block shape の観測も、clipboard の中身確認も、ブラウザコンソールだけでは追いにくかった。
観測手段を持っておくことは、設計変更の精度に直接影響します。