vivliostyle / vfm

⬇️ Open and extendable Markdown syntax and toolchain.
https://vivliostyle.github.io/vfm/#/vfm
Other
71 stars 12 forks source link

テーブルでルビを指定するとパイプ文字がセルの区切りにパースされる #44

Open ogwata opened 3 years ago

ogwata commented 3 years ago

Issue Details

Describe the bug

以下のようなMarkdownをVFMでパースします。

| ヘッダ 1 | ヘッダ 2 | ヘッダ 3 |
| ------------- | ------------- | ------------- |
| {内容|ないよう}セル   | 内容セル  |内容セル  |
| 内容セル  | 内容セル  |内容セル  |

すると以下のようなHTMLが出力されます。

<table><thead><tr><th>ヘッダ 1</th><th>ヘッダ 2</th><th>ヘッダ 3</th></tr></thead><tbody><tr><td>{内容</td><td>ないよう}セル</td><td>内容セル</td></tr><tr><td>内容セル</td><td>内容セル</td><td>内容セル</td></tr></tbody></table>
</body>

ルビの構文の {内容|ないよう} の|がテーブルのセルの区切りになってしまってます。

create-book で出力されたPDFは下記のとおり。

akabekobeko commented 3 years ago

@ogwata テーブル解析は VFM 独自ではなく remark という基礎部分のライブラリーになります。書式としては GFM (GitHub Flavored Markdown) 相当です。そして GFM ではテーブル内に | (pipe) を含められません。本件と同様にセル区切りとして解釈されます。

|header|header|
|---|---|
|`|`|value|

<table>
<thead><tr><th>header</th><th>header</th></tr></thead>
<tbody><tr><td>`</td><td>`</td></tr></tbody>
</table>

になります。期待値は | がセル内で <code>|</code> となることですが pipe が優先されてしまいました。そのうえ、ここでカラム数が最大となるため後続の |value| セルは無視されます。

単純に文字として pipe を描画したいなら HTML の数値文字参照を利用して &#124; または &#x7C; を指定します。手元の VFM v1.0.0-alpha.11 で小形さんの Markdown を {内容&#124;ないよう}セル に変えたものを渡したら pipe 処理されず {内容|ないよう}セル となりました。MDAST では {内容|ないよう}セル が分割された text ですね。

ただし数値文字参照なので pipe ではなく <ruby> としては処理されません。

akabekobeko commented 3 years ago

本件を解決するとしたら

  1. テーブル処理を remark とせず独自に再実装
  2. テーブルと pipe を両立させるなら Markdown ではなく HTML 埋め込みにしていただく

のいずれかになるでしょう。理想としては 1 ですが実装コストは高そうです。2 はエンドユーザーの手を煩わせることにはなりますが、本件のように VFM ではなくベースとなる GFM (remark) 部分へ手を入れるリスクに比べれば安全な方法です。

akabekobeko commented 3 years ago

@ogwata 以上を踏まえてどのような方針でゆくのがよいか、意見をください。

AyumuTakai commented 3 years ago

{内容|ないよう}と{内容:ないよう}の両方をルビに使えるようにするとかどうでしょう。 これならruby.tsの正規表現を修正するだけのような気がします。

自分も以前、テーブル内に|を含めようとして結局実体参照に行きついたのですが、 "\|"などでエスケープしてくれるのが一番ユーザとしてはありがたいです。

akabekobeko commented 3 years ago

@AyumuTakai ルビの区切り文字を追加する提案について #10 で意見を募ることにしました。

自分も以前、テーブル内に|を含めようとして結局実体参照に行きついたのですが、 "|"などでエスケープしてくれるのが一番ユーザとしてはありがたいです。

これはご指摘のとおりで私も GFM に期待していたことなのですが難しそうです。おそらく GFM で採用されていないのは <code> などを考慮する必要があるからだと予想しています。

spring-raining commented 3 years ago

詳しい実装は見ていませんが、こちらの問題はrubyのパースルールの優先度を高めれば解決できるように見えます。 tableのパースより先にrubyのパースが実行されれば、誤って | のパースがされることはないと思うのですが、どうでしょうか?

akabekobeko commented 3 years ago

試しに rehive-parse.ts の配列先頭に ruby を移動してみたらテストがエラーになりますね。テーブルは remarkParse の GFM か CommonMark だと思われますが、ここに割り込む方法はあるのでしたっけ?方針としてはよさそうなので、この方法を継続調査してみます。

akabekobeko commented 3 years ago

もうひとつ懸念として他のブロック書式、例えば Code block はコードそのものの例示に利用されるため、これよりも後にする必要があります。remark 部分の記法へ割り込む場合、それら全体を踏まえて優先順位を決めることになります。

MurakamiShinyu commented 3 years ago

GitHub Flavored Markdown Spec のテーブルセル内に | を入れる例 https://github.github.com/gfm/#example-200 を見ると

Include a pipe in a cell's content by escaping it, including inside other inline spans:

| f\|oo  |
| ------ |
| b `\|` az |
| b **\|** im |
結果: f\ oo
b \| az
b | im

で、テーブル内の `\|` では <code>|</code> が出力されます。現在のvfmではテーブルの外の `\|` と同じく <code>\|</code> が出力されるので、GFM仕様と違ってます。 テーブルの処理がされたあとほかの処理をする前に \| の2文字を | の1文字に置換する処理を入れると、GFMと同様になると思います。

そうすると、でんでんマークダウンのルビ記法でもテーブル内では | の代わりに \| と書けばよいということになります。

GFMの流儀に従い、「テーブル内では | はセルの区切り文字なので代わりに \|と書くこと」という決まりにするのでよい気がしてきました。

ルビの中に文字として "|" を入れることがテーブル内では不可能になるという問題がありますが、そのような場合はHTMLのrubyタグを書けばよいと言えます。

MurakamiShinyu commented 3 years ago

現在のvfmではテーブルの外の \| と同じく | が出力されるので、GFM仕様と違ってます。

これは remark のバグで、そのissueが最近登録されていました:

https://github.com/remarkjs/remark/issues/583 (Backslashes appear in output when escaping inline code in a table cell)

とりあえずremark側でこれが解決されたら、「テーブル内では | はセルの区切り文字なので代わりに \| と書くこと」と言えるようになります。

MurakamiShinyu commented 3 years ago

↑そのremarkのissueにあるコメントを読むと、最新のremark-parseとremark-htmlで解決されているようです。

akabekobeko commented 3 years ago

手元で試そうと npm を軒並み更新してテストを実行したら、最新の TypeScript だと判定が厳しくなるものがありエラーが結構でますね。いずれにせよ npm 更新はするべきなので対処してみます。現行のテストをすべて追加したら | のエスケープをテストへ追加して試します。

akabekobeko commented 3 years ago

調査メモ。手元で npm を一括更新したらテストがエラーになる。

jest 26.1.0 to 26.6.3、ts-jest 26.1.1 to 26.4.4

metadata.ts の以下がエラーになる。

file.data = {
  ...file.data,
  ...yaml(node.value),
};

ymalobject 以外も返すのに Spread を利用しているのが原因。VS Code 上でも警告される。

src/plugins/metadata.ts:24:7 - error TS2698: Spread types may only be created from object types.

    24       ...yaml(node.value),
             ~~~~~~~~~~~~~~~~~~~

当初 typescript を 3.9.5 から 4.1.3 へ更新したことが原因だと予想していたが jest と ts-jest を更新することで発生。yaml の戻り値を if で明示的に object と型判定することで回避可能。これは妥当なエラーである。

akabekobeko commented 3 years ago

remark-parse 8.0.2 to 9.0.0

以下のような tokenizer の動的追加がすべてエラーになる。

const { blockTokenizers, blockMethods } = this.Parser.prototype;
blockTokenizers.fencedBlock = tokenizer;

エラーは以下。

TypeError: Cannot set property 'fencedBlock' of undefined

      61 |
      62 |   const { blockTokenizers, blockMethods } = this.Parser.prototype;
    > 63 |   blockTokenizers.fencedBlock = tokenizer;
         |                              ^
      64 |   blockMethods.splice(blockMethods.indexOf('text'), 0, 'fencedBlock');
      65 | };
      66 |

      at Function.mdast (src/plugins/fenced-block.ts:63:30)
      at Function.freeze (node_modules/unified/index.js:128:28)
      at Object.<anonymous> (tests/utils.ts:9:38)

remark-parse に破壊的な変更があったのかもしれない。この npm さえ更新しなければテストは通るのだが本件対応に必須なのと npm 依存管理的になるべく最新としてゆきたいので継続調査する。

akabekobeko commented 3 years ago

以下を読むと remark 周りはかなり変更されているようなので参考にしながら対応する。

akabekobeko commented 3 years ago

テストを jest --silent=false --verbose false にして以下のようにログ出力を仕掛けてから実行。

const { blockTokenizers, blockMethods } = this.Parser.prototype;
console.log(blockTokenizers);
blockTokenizers.fencedBlock = tokenizer;

remark-parse 更新前

    console.log
      {
        blankLine: [Function: blankLine],
        indentedCode: [Function: indentedCode],
        fencedCode: [Function: fencedCode],
        blockquote: [Function: blockquote],
        atxHeading: [Function: atxHeading],
        thematicBreak: [Function: thematicBreak],
        list: [Function: list],
        setextHeading: [Function: setextHeading],
        html: [Function: blockHtml],
        definition: [Function: definition],
        table: [Function: table],
        paragraph: [Function: paragraph]
      }

      at Function.exports.mdast (src/plugins/fenced-block.ts:63:11)

9.0.0 へ更新後。

    console.log
      undefined

      at Function.mdast (src/plugins/fenced-block.ts:63:11)

エラーの指摘どおり blockTokenizers を得られず undefined となり、そこへプロパティー追加していることが問題。

akabekobeko commented 3 years ago

this.Parser.prototypeconsole.dir で出力してみる。変更前。

console.dir
      Parser {
        options: {
          position: false,
          gfm: true,
          commonmark: true,
          pedantic: false,
          blocks: [
            'address',  'article',    'aside',    'base',
            'basefont', 'blockquote', 'body',     'caption',
            'center',   'col',        'colgroup', 'dd',
            'details',  'dialog',     'dir',      'div',
            'dl',       'dt',         'fieldset', 'figcaption',
            'figure',   'footer',     'form',     'frame',
            'frameset', 'h1',         'h2',       'h3',
            'h4',       'h5',         'h6',       'head',
            'header',   'hgroup',     'hr',       'html',
            'iframe',   'legend',     'li',       'link',
            'main',     'menu',       'menuitem', 'meta',
            'nav',      'noframes',   'ol',       'optgroup',
            'option',   'p',          'param',    'pre',
            'section',  'source',     'title',    'summary',
            'table',    'tbody',      'td',       'tfoot',
            'th',       'thead',      'title',    'tr',
            'track',    'ul'
          ]
        },
        interruptParagraph: [
          [ 'thematicBreak' ],
          [ 'list' ],
          [ 'atxHeading' ],
          [ 'fencedCode' ],
          [ 'blockquote' ],
          [ 'html' ],
          [ 'setextHeading', [Object] ],
          [ 'definition', [Object] ]
        ],
        interruptList: [
          [ 'atxHeading', [Object] ],
          [ 'fencedCode', [Object] ],
          [ 'thematicBreak', [Object] ],
          [ 'definition', [Object] ]
        ],
        interruptBlockquote: [
          [ 'indentedCode', [Object] ],
          [ 'fencedCode', [Object] ],
          [ 'atxHeading', [Object] ],
          [ 'setextHeading', [Object] ],
          [ 'thematicBreak', [Object] ],
          [ 'html', [Object] ],
          [ 'list', [Object] ],
          [ 'definition', [Object] ]
        ],
        blockTokenizers: {
          blankLine: [Function: blankLine],
          indentedCode: [Function: indentedCode],
          fencedCode: [Function: fencedCode],
          blockquote: [Function: blockquote],
          atxHeading: [Function: atxHeading],
          thematicBreak: [Function: thematicBreak],
          list: [Function: list],
          setextHeading: [Function: setextHeading],
          html: [Function: blockHtml],
          definition: [Function: definition],
          table: [Function: table],
          paragraph: [Function: paragraph]
        },
        inlineTokenizers: {
          escape: [Function: escape] { locator: [Function: locate] },
          autoLink: [Function: autoLink] {
            locator: [Function: locate],
            notInLink: true
          },
          url: [Function: url] { locator: [Function: locate], notInLink: true },
          email: [Function: email] { locator: [Function: locate], notInLink: true },
          html: [Function: inlineHTML] { locator: [Function: locate] },
          link: [Function: link] { locator: [Function: locate] },
          reference: [Function: reference] { locator: [Function: locate] },
          strong: [Function: strong] { locator: [Function: locate] },
          emphasis: [Function: emphasis] { locator: [Function: locate] },
          deletion: [Function: strikethrough] { locator: [Function: locate] },
          code: [Function: inlineCode] { locator: [Function: locate] },
          break: [Function: hardBreak] { locator: [Function: locate] },
          text: [Function: text]
        },
        blockMethods: [
          'blankLine',  'indentedCode',
          'fencedCode', 'blockquote',
          'atxHeading', 'thematicBreak',
          'list',       'setextHeading',
          'html',       'definition',
          'table',      'paragraph'
        ],
        inlineMethods: [
          'escape',    'autoLink',
          'url',       'email',
          'html',      'link',
          'reference', 'strong',
          'emphasis',  'deletion',
          'code',      'break',
          'text'
        ]
      }

      at Function.exports.mdast (src/plugins/fenced-block.ts:63:11)

9.0.0 へ更新後。

console.dir
      {}

      at Function.mdast (src/plugins/fenced-block.ts:63:11)

空のオブジェクトになっている。根本的な構造が変化しているようだ。そのため revive-parse で呼び出しているものは処理を変更する必要あり。

akabekobeko commented 3 years ago

remark-parse は micromark へ移行したのでプラグイン実装の変更が必要。

ここにリストアップされてるものでも動作しないプラグインは作者への対応を呼びかけているようだ。対応済のプラグインについてコミット履歴を読めば VFM 修正の参考になるだろう。

akabekobeko commented 3 years ago

ruby 以外の全般的な対応が必要なため新 remark-parse 関連は #45 で対応します。そちらが完了することで最新の remark-parse と独立化された remark-gfm を採用することになり、結果として村上さんの調査結果となる

これは remark のバグで、そのissueが最近登録されていました:

https://github.com/remarkjs/remark/issues/583 (Backslashes appear in output when escaping inline code in a table cell)

とりあえずremark側でこれが解決されたら、「テーブル内では | はセルの区切り文字なので代わりに \| と書くこと」と言えるようになります。

が反映されるはずです。

akabekobeko commented 3 years ago

前述のように本件は v2.0 へ見送ります。