$x < 0$のような比較式がMathML化されない不具合を修正しました。- 原因は1つではなく、tokenizer段階の判定漏れと、貼り付け後段のHTML content内でHTMLエンティティ化された比較式の見落としが重なっていました。
- 実機の失敗結果を見るまで、2つ目の問題は存在すら気づけませんでした。
1. 発端
GutenbergにLaTeXを貼り付けるとMathMLに変換するWordPressプラグインを作っています。
$x < 0$ を含む文章を貼り付けると、比較式だけMathMLに変換されずそのまま残る、という不具合がありました。
最初はtokenizerだけの問題だと思っていました。isValidInlineLatex() が < を含む式を数式と判定できていないはずだ、と。
修正してテストを通したあと、実機の失敗結果で確認しました。
そこで問題が2層に分かれていると初めて分かりました。
2. 問題1:tokenizerが比較式を落としていた
src/latex-segments.js の isValidInlineLatex() は、数式らしさの判定をいくつかのシグナルで行っています。
- LaTeXコマンド(
\sqrt、\neqなど) - 構造記号(
^、_、{、}、=) - 二項演算式(
x+yなど) - 関数記法(
f(x)) - 短い変数名(
x、z_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$ のままになっています。
変換できているものとできていないものが混在していました。
そのファイルに含まれていたblock content の実態がこれです。
$x < 0$
$x > 0$Code language: HTML, XML (xml)
< と > がHTMLエンティティに変換されています。
editor.js では、貼り付け後にblock treeを走査してinline LaTeXを後処理変換しています。
この後処理はtokenizerに文字列を渡すのですが、渡す前にエンティティをデコードしていませんでした。
tokenizerは < を比較演算子として認識できても、< は別の文字列なので素通りしていました。
なぜエンティティになるかというと、pasteHandler が返すblock treeの content にはすでにHTML化された状態の文字列が入っているためです。< > のエスケープ自体はHTMLとして正しい動作ですが、後処理変換の入力としては扱いにくい状態でした。
修正は後処理の入力段階でデコードを挟むようにしました。editor.js に decodeHtmlEntities() を追加して、text nodeをtokenizerに渡す前に適用しています。
function decodeHtmlEntities(text) {
return String(text)
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/&/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 < 0</mi></math> では ...'Code language: JavaScript (javascript)
MathMLの中では x < 0 という形で出力されます。
エンティティデコードはtokenizerへの入力前にだけ行い、MathML生成後の出力は再エスケープされる流れになっています。
4. 実機観測が不可欠だった理由
ローカルでplain textを入力してtokenizer単体をテストするだけでは、2つ目の問題は見えませんでした。
pasteHandler が返すblock treeの content がどういう状態になっているかは、実機で確認するしか方法がありません。
モック上では素のHTMLを返すように書けば $x < 0$ のままで届きますが、実機では $x < 0$ になっています。
前回の attributes.content がobjectになる問題と同じ構図で、Gutenbergのblock attributeは「渡したのと同じ形で返る」という前提が崩れることがあります。
実機のファイルで差異を確認できたことが、修正を正しい方向に向けるきっかけでした。
5. 今回の教訓
比較演算子 < > は数式の文脈では自然なトークンですが、HTMLの文脈ではエスケープ対象です。
tokenizer側で認識できるようにしても、渡す前の状態が < になっていれば認識できません。
入力と出力それぞれがどの文脈にいるかを意識して、デコードのタイミングを明示的に管理する必要があります。
tokenizer単体のテストが通っても、貼り付け後段のblock treeで問題が起きることがあります。
再現テストで後段まで固定しておかないと、実機でしか見えない差異が残り続けます。