【WordPressプラグイン】
比較式が変換されない原因が
2層に分かれていた

  • $x < 0$ のような比較式がMathML化されない不具合を修正しました。
  • 原因は1つではなく、tokenizer段階の判定漏れと、貼り付け後段のHTML content内でHTMLエンティティ化された比較式の見落としが重なっていました。
  • 実機の失敗結果を見るまで、2つ目の問題は存在すら気づけませんでした。

関連記事

1. 発端

GutenbergにLaTeXを貼り付けるとMathMLに変換するWordPressプラグインを作っています。

比較式が変換されない不具合 $x < 0$ Gutenbergに貼り付け 変換 <math>…</math> ✓ $x < 0$ のまま残る MathML化されない ✗ <math>…</math> ✓ 比較式だけ変換されない 原因は1つではなく、2層に分かれていた

$x < 0$ を含む文章を貼り付けると、比較式だけMathMLに変換されずそのまま残る、という不具合がありました。

最初はtokenizerだけの問題だと思っていました。
isValidInlineLatex()< を含む式を数式と判定できていないはずだ、と。
修正してテストを通したあと、実機の失敗結果で確認しました。
そこで問題が2層に分かれていると初めて分かりました。

2. 問題1:tokenizerが比較式を落としていた

src/latex-segments.jsisValidInlineLatex() は、数式らしさの判定をいくつかのシグナルで行っています。

問題1:tokenizer の判定漏れ 従来の判定シグナル \sqrt, \neq などのコマンド ^ _ { } = などの記号 x+y などの二項演算 f(x) などの関数記法 x, z_1 などの変数名 対象外 $x < 0$ 修正後の追加判定 looksLikeShortNaturalMath 24文字以内の短い式に限定 x < 0 比較式 ✓ (x, y) 順序対 ✓ x 単一変数 ✓ 誤検出を増やさず、短い自然な数式を認識可能に
  • LaTeXコマンド(\sqrt\neq など)
  • 構造記号(^_{}=
  • 二項演算式(x+y など)
  • 関数記法(f(x)
  • 短い変数名(xz_1

$x < 0$ はどれにも当てはまりません。
<> も判定に入っていませんでした。
$a > b$$(x, y)$ も同じ理由で落ちていました。

修正は判定を二層に整理しました。
従来の強いシグナル群に加え、短い自然な数式パターンを looksLikeShortNaturalMath() として別枠で定義する形です。

function looksLikeShortNaturalMath(text) {
    if (!/^[A-Za-z0-9\s(),.+\-*/<>]+$/.test(text)) {
        return false;
    }
    if (ORDERED_PAIR_PATTERN.test(text)) { return true; }   // (x, y)
    if (SIMPLE_COMPARISON_PATTERN.test(text)) { return true; }  // x < 0
    if (SIMPLE_IDENTIFIER_PATTERN.test(text)) { return true; }  // x
    return false;
}Code language: JavaScript (javascript)

長い自然文は引き続き除外しています。
SHORT_INLINE_LATEX_MAX_LENGTH で定めた24文字を超えるものはこの経路に入りません。
短さと構造の単純さを条件にすることで、通貨表現のような誤検出を増やさずに済みます。

テストは tests/unit/latex-segments.test.js に追加しました。

test('detects short inline inequality like $x < 0$', () => {
    const tokens = tokenizeLatexSegments('Condition $x < 0$ matters.');
    assert.equal(hasLatexSegments(tokens), true);
});

test('detects short ordered pair notation like $(x, y)$', () => {
    const tokens = tokenizeLatexSegments('Point $(x, y)$ is on the graph.');
    assert.equal(hasLatexSegments(tokens), true);
});Code language: JavaScript (javascript)

これでtokenizer単体のテストは通りました。
ここで終わりだと思っていました。

3. 問題2:後段でHTMLエンティティになっていた

実機の失敗結果ファイルを見ると、一部は変換されているのに一部だけ $x < 0$ のままになっています。
変換できているものとできていないものが混在していました。

問題2:後段でHTMLエンティティになっていた pasteHandler block tree を返す block content $x &lt; 0$ < → &lt; にエスケープ済み tokenizer &lt; を認識できない 修正:デコードを挟む decodeHtmlEntities() &lt; → < に復元 tokenizer が < を認識 MathML 変換成功 ✓

そのファイルに含まれていたblock content の実態がこれです。

$x &lt; 0$
$x &gt; 0$Code language: HTML, XML (xml)

<> がHTMLエンティティに変換されています。

editor.js では、貼り付け後にblock treeを走査してinline LaTeXを後処理変換しています。
この後処理はtokenizerに文字列を渡すのですが、渡す前にエンティティをデコードしていませんでした。
tokenizerは < を比較演算子として認識できても、&lt; は別の文字列なので素通りしていました。

なぜエンティティになるかというと、pasteHandler が返すblock treeの content にはすでにHTML化された状態の文字列が入っているためです。
< > のエスケープ自体はHTMLとして正しい動作ですが、後処理変換の入力としては扱いにくい状態でした。

修正は後処理の入力段階でデコードを挟むようにしました。
editor.jsdecodeHtmlEntities() を追加して、text nodeをtokenizerに渡す前に適用しています。

function decodeHtmlEntities(text) {
    return String(text)
        .replace(/&lt;/g, '<')
        .replace(/&gt;/g, '>')
        .replace(/&amp;/g, '&');
}Code language: JavaScript (javascript)

あわせて、HTMLタグ判定のパターンも見直しました。
従来の実装は <...> を広くタグ扱いしていたため、比較演算子と衝突する可能性がありました。
HTML_TAG_PATTERN/<\/?[A-Za-z][^>]*>/ に絞り、タグと演算子を分離しています。

テストは tests/unit/editor-markdown-coexistence.test.js に追加しました。
複数段落・display数式・inline数式・比較式が混在する長文を貼り付けたときの挙動を固定しています。

// 期待するblock contentの一部
'<math>...</math> であって、<math><mi>x &lt; 0</mi></math> では ...'Code language: JavaScript (javascript)

MathMLの中では x &lt; 0 という形で出力されます。
エンティティデコードはtokenizerへの入力前にだけ行い、MathML生成後の出力は再エスケープされる流れになっています。

4. 実機観測が不可欠だった理由

ローカルでplain textを入力してtokenizer単体をテストするだけでは、2つ目の問題は見えませんでした。

実機観測が不可欠だった理由 モック環境 content = “$x < 0$” テスト通過 ✓(見かけ上) 素の文字列で届くため エンティティの問題は見えない vs 実機環境 content = “$x &lt; 0$” 変換失敗 → 問題を発見 Gutenberg が自動エスケープ 差異を確認して修正方向が定まった block attribute は「渡したまま返る」とは限らない

pasteHandler が返すblock treeの content がどういう状態になっているかは、実機で確認するしか方法がありません。
モック上では素のHTMLを返すように書けば $x < 0$ のままで届きますが、実機では $x &lt; 0$ になっています。
前回の attributes.content がobjectになる問題と同じ構図で、Gutenbergのblock attributeは「渡したのと同じ形で返る」という前提が崩れることがあります。

実機のファイルで差異を確認できたことが、修正を正しい方向に向けるきっかけでした。

5. 今回の教訓

比較演算子 < > は数式の文脈では自然なトークンですが、HTMLの文脈ではエスケープ対象です。
tokenizer側で認識できるようにしても、渡す前の状態が &lt; になっていれば認識できません。
入力と出力それぞれがどの文脈にいるかを意識して、デコードのタイミングを明示的に管理する必要があります。

tokenizer単体のテストが通っても、貼り付け後段のblock treeで問題が起きることがあります。
再現テストで後段まで固定しておかないと、実機でしか見えない差異が残り続けます。