Closed11

zenn-editor の 数式検出正規表現を解読する

waddy_uwaddy_u

やりたいことまとめ

  • Markdown エディタで 数式を KaTeX にレンダリングさせるべく正規表現で $~$ を抜き出している
  • 抜き出したくないケースもあり(HTTPリンクに埋め込まれているケースなど)やや複雑化してきている
  • 意図しないマッチもISSUEにあげていただいたこともありこの機会に解読することに
waddy_uwaddy_u

当該箇所

https://github.com/zenn-dev/zenn-editor/blob/canary/packages/zenn-markdown-html/src/utils/md-katex.ts#L35-L67

  {
    name: 'math_inline',
    rex: /\$((?:\S)|(?:\S(?!.*\]\(http.*\$.*\)).*?\S))\$/gy, // fixed so that the expression [$something](https://something.com/$example) is skipped. (?:\S(?!.*\]\(http.*\$.*\)) means somthing like "](https://hoge.com/$/hoge)"
    tmpl: `<embed-katex><eq class="${katexClassName}">$1</eq></embed-katex>`,
    tag: '$',
    pre: preHandler,
    post: postHandler,
  },

ウゥメマイガ…

waddy_uwaddy_u

把握する

ビジュアライズ

regulexというサイトを使わせていただく。

  • でかい OR がある。ひとつめは \$(?:\S)\$
    • $~$ で囲まれた制御文字以外の文字列にマッチする
    • 意図が謎。任意の一文字を数式にレンダリングするためかな?
    • $a$ => a みたいな…
    • どうやら一文字のときとそれ以外のときでマッチングを明確に分けたい意図があるっぽい
    • おそらくだが、1文字のときはややこしい除外判定がいらないから、というのがありそう
    • 1文字のときは除外判定の影響を受けないようにしたい、ということか
    • 複数文字のほうはこの先もいろいろ判定が追加されていく可能性があるため
  • ふたつめは \$(?:\S(?!.*\]\(http.*\$.*\)).*?\S)\$
    • なんとなくURLに含まれる$は無視したい意図はわかるが、少しずつ分解する
    • (?!.*\]\(http.*\$.*\) これ以外をマッチさせたい
    • 任意の文字列 ](http 任意の文字列 $ )
    • つまり、URLの中に $ が含まれている場合、その塊はキャプチャしないようにしたい、となるほど
waddy_uwaddy_u

この記号の意味なんですか (?:pattern)

置き換え、キャプチャパターンのひとつ。正規表現でキャプチャするとマッチした文字列を $0$1といった変数で抽出できるが、そのやり方にバリエーションがある。ここを参考にさせていだきました。

https://www.ipentec.com/document/software-regular-expression-difference-question-equal-and-question-colon

また、JavaScript MDNにも乗っている。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Guide/Regular_Expressions/Groups_and_Ranges

非キャプチャグループ: x にマッチしますが、マッチした内容は記憶しません。マッチの部分文字列は、結果の配列の要素 ([1], ..., [n]) や、あらかじめ定義されている RegExp オブジェクトのプロパティ ($1, ..., $9) から呼び出すことはできません。.

(?:pattern) は、パターンを含むマッチ文字列全体がひとつの変数に入る(=マッチ文字列が保持されない) という動きになります。文字で書いてもわからない。

ブラウザの開発者コンソール
/\$(?:\S*)\$/g.exec('それではこの数式を見てください。$a=b$')
["$a=b$"]    // キャプチャ部分のマッチは破棄され、正規表現全体のマッチのみが保持される

/\$(?:\S*)\$/g.exec('それではこの数式を見てください。$a=b$')
["$a=b$", "a=b"] (2) // キャプチャ部分のマッチも、正規表現全体のマッチも保持される

つまり、キャプチャグループの中は特にいらなくて、今回の場合は数式全体 $~$ が欲しいので (?:) を使っているということですね。

waddy_uwaddy_u

この記号の意味なんですか (?!pattern)

置き換え、キャプチャパターンのひとつ。やはりここを参考にさせていだきました。

https://www.ipentec.com/document/software-regular-expression-difference-question-equal-and-question-colon

MDNでは 言明として説明がありました。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Guide/Regular_Expressions/Assertions#その他の言明

(?!:pattern) は、パターンを含まない文字列全体がひとつの変数に入る という動きになります。文字で書いてもやっぱりわからない。

ブラウザの開発者コンソール
/\$(?!script).*\$/.exec('それではこの数式を見てください。$a=b$')
["$a=b$"]  // script に一致する文字列はないので $~$ が抽出される

/\$(?!script).*\$/.exec('それではこの数式を見てください。$script:alert("a=b")$')
null   // script に一致する文字列があるので抽出しない

例えば、上記のように危険な文字列、不利益な文字列などを含む場合はマッチさせないようにする、パターンをスキップする、といったケースで役に立ちそうですね。

waddy_uwaddy_u

問題になるケース、ならないケース

https://github.com/zenn-dev/zenn-community/issues/368

こちらのISSUEで教えていただいたケースを考察する。

問題がある

あああ$\omega$いいい『[uuu](https://arxiv.org)』えええ$(7)$おおお
👇
あああ\omegaいいい『uuu』えええ(7)おお

$\omega$ も数式になってほしいが、ならない

理由:

  • \omega$いいい『[uuu](https://arxiv.org)』えええ$(7)
  • この部分が、任意の文字列 ](http 任意の文字列 $ ) にマッチしてしまっている
  • スキップ対象の文字列を検知したので、この $~$ はキャプチャされない(数式変換対象にならない)

問題がない

あああ$a$いいい[uuu](https://arxiv.org)えええ$(7)$おおお
👇
あああaいいいuuuえええ(7)おおお

理由:

  • $~$で囲まれた任意の一文字は問答無用でキャプチャされるため意図に反してスキップされることがない

あああ$\omega$いいい『[uuu](https://arxiv.org)』えええ$(7$おお
👇
あああ\omegaいいい『uuu』えええ(7おお

理由:

  • これが決め手になった。(7 の閉じ括弧がないことで、 任意の文字列 ](http 任意の文字列 $ ) これにマッチすることがない
  • ということでひとつめの $\omega$ も正常に数式判定される
waddy_uwaddy_u

どうすれば良いか

URLに含まれる$の検出を厳密にする。

スキップ対象: 任意の文字列 ](http 任意の文字列 $ )

この部分の解釈が広がってしまったことで起きた。…そもそもURLに含まれる $ の検出が必要かしら?

https://github.com/zenn-dev/zenn-editor/issues/36

この対応の後、

https://github.com/zenn-dev/zenn-community/issues/168

https://github.com/catnose99/markdown-it-texmath/commit/d87ff46711573e8206296c827e66c3e97a8898c7#diff-1e26611a1fc2cb1f6b5a5459ebdfad9324858c7789c622a8667c6169a6240c7c

この対応があった。

Hidden comment
waddy_uwaddy_u

理屈ではなく神託を待ったら

緩和策がでたかもしれない。

\$((?:\S)|(?:\S(?![^\$]*\]\(http.*).*?\S))\$

これならどうだろう。これをベースにテストを書いていく。

意味的なやつ:

  • $$ に挟まれる1文字
  • $$ に挟まれる文字列で、次のパターンを 持たないもの全体
    • 制御以外1文字 $以外の任意の文字列 ](http 任意の文字列
    • 先読み否定でパターンを持たないってのがややこしい。要するに **間に](httpsって文字列があるからきっと数式判定したくないものだと思うけど、URLぽいやつの前に$記号があったらそれは $ ~ $ ](https $ というパターンにハマって意図せず広い範囲をスキップしてしまう可能性があるため、スキップする範囲を限定するため パターン中に$が含まれていたらスキップしない とした
  • この措置によって、$ ~ $ ](http $ の広い範囲にわたってスキップされることがなくなり、また (.*?) の最小マッチにより、 個別の $~$ が意図どおりにマッチするようになる。

理解の助け

神託とはいったけど実際にはかなり調べた。食事中も上の空で家族にはスマンコトヲシタ。で、一番勘違い(というかわかってなかった)のが (?!Pattern) 部分の 先読み否定。ここを理解していないと挙動がわからない。

先読みは文字通り一致するパターンを調べるわけなんだけど、Pattern自体は全体でどういうマッチをさせようとしているかを把握していない。ので、自分は自分で最後まで調べようとする。例えば、

あああ$\omega$いいい『[uuu](https://arxiv.org)』えええ$(7)$

こういう文字列に対して text](https://を含むパターンの場合は数式にマッチさせたくないな、というケース。コレに対して、

元の正規表現
\$((?:\S)|(?:\S(?!.*\]\(http.*\$.*\)).*?\S))\$

ひとつめの数式がマッチしない。これは、除外パターンに $\omega$いいい『[uuu](https://arxiv.org)』えええ$(7) が入っているから。 開始$、終了$で囲んでいるのにどうして、と思ったが、先に述べたとおりPatternはそのうしろでどのような正規表現があるかは気にしない。ので、http.*\$.*\)).*?\S) ここの、「$ではじまり、任意の文字列のあとhttpのあとに$と閉じ括弧)がある」というパターンに「$\omega$いいい『[uuu](https://arxiv.org)』えええ$(7)」が入っていたというわけ。

waddy_uwaddy_u

残念ながら完璧にするのは不可能

このやり方では対処出来ないパターンもある。

$a=b$という数式はこちらを参照[text$text](https://...$text)$(7)$

なんかがそう。ただ、これは$という、開始と終了が明確でない記号を使って数式を判定する限りパーフェクトは無理といえる。もし言語やDSLを設計するときは、開始と終了が明確なトークンを使うようにしような!

waddy_uwaddy_u

テスト用

https://zenn.dev/wsuzume/articles/4054bbe4948303

こちらを拝借。

昔対応したパターン(数式にならなければOK)

text$text

[text$text](https://...$text

=> バグっぽい。修正前は数式になっちゃうはず

$text](https://...$

=> バグっぽい。修正前は数式になっちゃうはず

修正によって解消されてほしいパターン

オメガも数式になっていればOK。修正前は数式になっていないはず。

あああ\omegaいいい『uuu』えええ(7)おおお
あああ\frac{1}{2}いいいuuuえええ(7)おおお
あああabcいいいuuuえええ(7)おおお
あああ\alpha\omegaいいいuuuえええ(7)おおお

修正前も修正後も正しいパターン

デグレチェック用

あああaいいいuuuえええ(7)おおお
あああ\omegaいいいえええ(7)おおお
あああ\omegaいいいuuuえええおおお
あああ\omegaいいいuuuえええ7おおお
あああ\omegaいいいuuuえええ(7)おおお
あああ\omegaいいいuuuえええ(7)おおお
あああ\omegaいいいuuuえええ(7)おおお
あああ\omegaいいい(https://arxiv.org)えええ(7)おおお
あああ\omegaいいいuuuえええ\exp xyzおおお

残念だが、解消できないかもしれないパターン

と思ったけど、リンク部分がアンカーになるならワンちゃんあるかも。

a=bという数式はこちらを参照text$text(7)

このスクラップは2022/01/24にクローズされました