CommonMark のフランキング規則が日本語と中国語のページに ** を出力する

CommonMark が ** で強調を閉じられるのは right-flanking のときだけです。日本語と中国語は句点のあとに空白がないため、太字がアスタリスクのままページに出ます。

白いカードに黒のMarkdownマーク(Mと下向き矢印を囲む角丸長方形)
目次

はじめに

3 言語で公開した記事の日本語ページを読もうとしたところ、太字になるはずだった一文の両端にアスタリスクが並んでいました。

**SIGTERM を受け取った Chrome は、終了する際に自分の SingletonLock を削除します。**これはテスト用に…

原因は CommonMark 側にあります。** のランが強調を閉じられるのは right-flanking のときだけです。句点と文字に挟まれたランは left-flanking でも right-flanking でもありません。英語がこの条件に引っかからないのは、文末の句点のあとに必ず空白が入るからです。CJK にはその空白がありません。

** が閉じられるかどうかを決めるもの

アスタリスクの並びは命令ではありません。それは候補にすぎず、その候補が何になれるかは両隣の 2 文字が決めます。

left-flanking と right-flanking

太字は括弧と同じように、ペアのランで囲まれます。片方が強調を開き、もう片方が閉じるのですが、パーサーはペアを組む前に、それぞれのランがどちらの役割に適格かを判定します。

どちらの役割にも適格でないランはエラーになりません。アスタリスク 2 つのままページに残ります。

適格かどうかはランごとに、しかも隣接だけで決まります。ランが開き側になれるのは、強調したい本文にそのまま接しているときだけで、閉じ側になれるのも同じく本文に接しているときだけです。内側に空白が入ると、その接触が切れます。

This is **bold text** here both runs touch the text → renders
This is ** bold text** here opening run has a space → stays literal
This is **bold text ** here closing run has a space → stays literal

CommonMark はこの 2 つの適格性に名前を与え、それぞれをランの両隣の文字に対する条件として定めています。

  • left-flanking とは、そのランの直後が空白でなく、かつ「直後が約物でない」か「直前が空白か約物」のどちらかが成り立つことです。
  • right-flanking とは、そのランの直前が空白でなく、かつ「直前が約物でない」か「直後が空白か約物」のどちらかが成り立つことです。

どの節も主語はランそのものであり、「直後」とはすぐ後ろにある 1 文字を指します。left-flanking は開く適格性、right-flanking は閉じる適格性です。

描画されない太字で、どちらのランがなぜ失格になるか同じ文を 3 通りに書き、それぞれのアスタリスクのランにラベルを付けた図。1 つ目は開き側が left-flanking で開き、閉じ側が right-flanking で閉じるため、ページには太字が出る。2 つ目は開き側の直後が空白で、left-flanking でも right-flanking でもなくなり開けない。閉じ側は条件を満たしているが相方がいない。3 つ目は同じことが閉じ側に起きる。壊れた 2 文はアスタリスクが付いたまま表示される。書いたものページに出るものThis is **bold text** here開き側のラン · left-flanking ✓ · 開く閉じ側のラン · right-flanking ✓ · 閉じるThis is bold text hereThis is **␣bold text** here開き側のラン · どちらでもない ✗ · 開けない閉じ側のラン · right-flanking ✓ · 条件は満たすが相方がいないThis is ** bold text** hereThis is **bold text␣** here開き側のラン · left-flanking ✓ · 条件は満たすが相方がいない閉じ側のラン · どちらでもない ✗ · 閉じられないThis is **bold text ** here強調は両方の役割がそろって初めて成立するので、片方が失格になるだけでアスタリスクは残ります。
同じ文を 3 通りに書き、それぞれのランにラベルを付けたもの。内側の空白が失格にするのはどちらか一方のランだけで、もう一方は条件を満たしたまま相方を失います。それだけで両端のアスタリスクはページに残ります。

ランが強調を開けるのは left-flanking のときだけ、閉じられるのは right-flanking のときだけです。ドキュメントの他の要素は一切関係しません。段落も、対になるランも関係なく、効くのは直前と直後の 1 文字だけです。

デリミタランを決める 2 つの文字`**` のランを、その直前の文字と直後の文字の間に置いた図と、4 通りの組み合わせの表。両側が文字なら開くことも閉じることもできる。直前が約物で直後が文字の場合は開けるが閉じられず、これが CJK で壊れる組み合わせ。直前が文字で直後が約物なら閉じられるが開けない。直前が約物で直後が空白なら閉じられる。パーサーが読むもの?直前の文字**?直後の文字直前 / 直後開ける閉じられる文字 / 文字可可約物 / 文字可不可文字 / 約物不可可約物 / 空白不可可2 行目が CJK で起きる形。句点のあとに文字が続く閉じ側です。
ランの両隣の 2 文字がすべてを決めます。直前が約物の閉じ側は、直後に空白か約物を必要とします。そこに文字が来ると失格です。

閉じ側のケースは、実際に牙をむくところなので丁寧に追ってください。直前が約物のランが right-flanking になるのは、直後が空白か約物のときだけです。左が約物で右が文字という並びは、どちらの節も満たしません。

英語の句点がつねに安全な理由

英語は句点のあとに空白を書きます。この空白が効いています。

**It removes the lock.** This is a test.

閉じ側のランは直前が . で直後が空白なので、right-flanking の 2 つ目の節を満たし、ランは閉じます。英語で問題が起きないのは、この空白があるからにすぎません。英語の文末の太字は放っておいてもこの形になるため、英語で書いているかぎりこの規則は目に入りません。

同じ文が英語では動き日本語では動かない理由同じ太字の文を 2 言語で並べた図。英語では閉じ側のランの直後に空白があり、右フランキングの条件を満たすので強調が閉じる。日本語では次の文がすぐ始まるため閉じ側の直後に文字が来て、ランはアスタリスクのままページに残る。英語**It removes the lock.**␣This is…閉じ側の次が空白 → 閉じる日本語**ロックを削除します。**これは…閉じ側の次が文字 → そのまま残る
同じ文を 2 言語で並べたもの。英語の句点のあとの空白が閉じ側を right-flanking にしています。日本語にはその位置に何もありません。

日本語と中国語は文と文のあいだに空白を置きません。そのため閉じ側のランは次の文の 1 文字目に直接くっつき、そこに来るのはたいてい約物ではない普通の文字です。

CJK で失敗する 3 つの形

CJK の技術文書では次の 3 つの並びが頻繁に出てきて、いずれも開くことも閉じることもできないランになります。

…削除します。**これは closing run: 。 on the left, こ on the right
…対象を**「引用」**とする opening run: を on the left, 「 on the right
…ではなく**`client-id`**です opening run: く on the left, ` on the right

1 つ目は太字がそのまま文末で終わる形です。2 つ目はかぎ括弧で囲んだ語句で、かぎ括弧が約物なので開き側が失格になります。3 つ目はインラインコードを太字で囲んだ形で、バッククォートが同じ働きをします。

どの直し方も、内側に空白を足すのではなく、約物を強調の外に出します。

…削除します**。これは
…対象を「**引用**」とする
…ではなく **`client-id`** です
失敗する 3 つの形とその直し方3 行の図。句点で終わる太字、かぎ括弧で囲んだ語を含む太字、インラインコードを囲む太字。それぞれ失敗する形と直した形を並べている。最初の 2 つは約物を強調の外に出し、3 つ目はバッククォートを動かせないためデリミタの外側に空白を入れる。そのまま残る描画される…削除します。**これは…削除します**。これは文末の 。…対象を**「引用」**とする…対象を「**引用**」とするかぎ括弧…ではなく**`client-id`**です…ではなく **`client-id`** ですインラインコード空白が要るのは 3 つ目だけ。ほかの 2 つは文字を強調の外に出します。
3 つの形とそれぞれの直し方。最初の 2 つは文字を強調の外に出すだけですが、バッククォートを動かせない 3 つ目だけは外側の空白に頼ります。

3 つ目の直し方は見た目が悪くなります。日本語の文字と太字のあいだに空白が見えてしまうからです。それでも、動かせないバッククォートにデリミタが接している場合はこれしかありません。

4 回のスイープと、3 つの段落を壊した 1 回

手作業で直すのは現実的ではなかったので、スイープをかけました。失格になった閉じ側なら文末の約物を外に出し、失格になった開き側なら対の括弧を外に出し、どちらでもなければ空白に頼る、という処理です。

壊したのは 3 回目のスイープです。デリミタのランを行ごとに対応づけたのですが、段落が折り返されているとこれが間違いになります。

…把同一段流程分別跑在釋出版與修正版的打包檔上,**組字中的狀態完全相同,
結果卻不同**:

2 行目だけを読むと、その行で最初に現れる ** なので、行ごとの処理はこれを開き側と判定します。実際は閉じ側です。スイープは left-flanking かどうかを調べ、満たしていないと判断して手前に空白を挿入しました。閉じ側から right-flanking を奪うのは、まさにその空白です。

行ごとの対応づけは閉じ側を開き側と読む2 行のソース行に折り返された 1 つの段落で、太字のランが 1 行目で開き 2 行目で閉じている。行ごとに読むと 2 行目のランはその行で最初に現れるため開き側として扱われ、修正パスがその手前に空白を入れて壊してしまう。段落ごとに読めば 2 番目のランであり、正しく閉じ側として扱われる。2 行に折り返された 1 つの段落…打包檔上,**組字中的狀態完全相同,結果卻不同**:ラン 1 ・開き行ごとに対応づけるラン 1 ・開き誤り: 手前に空白が入れられる結果卻不同␣**:段落ごとに対応づけるラン 2 ・閉じ正しい: 閉じ側として判定され、そのまま結果卻不同**:
同じ閉じ側のランを 2 通りに読んだもの。行ごとに対応づけると最初のランになり開き側と見なされますが、段落ごとに対応づければ 2 番目のまま、つまり本来の役割のままです。

直す処理がその欠陥自体を作りうるなら、同じ検査をその出力にも走らせる必要があります。壊した 3 つの段落が見つかったのは、あとで全ファイルをレンダラーに通したからで、4 回目のスイープで元に戻しました。

いま塞いでいる検査

規則は前後 2 文字だけで決まるので、検査も短く済みます。ランを段落ごとに対応づけ、両方のフランキング判定を適用し、必要な役割を果たせないランを報告します。

scripts/prose-check.ts
const canOpen = !isSpace(next) && (!isPunct(next) || isSpace(prev) || isPunct(prev));
const canClose = !isSpace(prev) && (!isPunct(prev) || isSpace(next) || isPunct(next));
const closing = i % 2 === 1;
if (closing ? canClose : canOpen) continue;

見た目以上に効いている点が 2 つあります。段落の区切りは空行です。行番号の飛びで区切ってしまうと、コードフェンスに挟まれた地の文がまるごと 1 つの単位として扱われます。

もう 1 つは、ランの数が奇数なら飛ばさずに報告することです。単独の ** は必ずそのまま描画されるので、それ自体が欠陥です。その範囲を飛ばすと、以降のテキストは検査されないのに、報告される件数だけはきれいなままです。

失敗するところを誰も見たことがない検査は検査ではないので、直したファイルをわざと壊しました。

ターミナルウィンドウ
入力
node scripts/prose-check.ts <slug> ja
出力
emphasis delimiters that cannot render: 1
line 184 closing …を削除します。**これはテスト用に起動した…

ファイルを元に戻すと 0 件、終了コード 0 になりました。この検査が走るのは ja と zh-tw だけです。英語ではこの形を作れません。

まとめ

  1. ** のランが強調を開けるのは left-flanking のときだけ、閉じられるのは right-flanking のときだけで、どちらの判定も直前と直後の 1 文字しか見ません。
  2. 直前が約物の閉じ側は直後に空白か約物を必要とします。そのため 。**これは は開くことも閉じることもできず、アスタリスクのまま出荷されます。
  3. 英語は句点のあとに必ず空白を書くので、この条件に引っかかりません。この欠陥はソース言語では見えず、翻訳先の言語で構造的に発生します。
  4. 直し方は約物を強調の外に出すこと。デリミタがバッククォートに接している場合の逃げ道が、外側の空白です。
  5. デリミタは段落ごとに対応づけること。折り返された段落は行頭に閉じ側が来ることがあり、行ごとの処理はそれを開き側と読みます。
  6. 規則は 2 文字ぶんの幅しかありません。だからこそ、レビューで見るのではなく機械で検査する価値があります。

参考リンク

この記事をシェア