C#でMarkdownをパースしてオリジナルの出力を行う(markdigを使用)


はじめに

今回MarkdownをPowerPoint形式のファイル(*.pptx)に変換するツールを作成しました。 その中での技術的なメモをまとめておきます。   今回記述するのはMarkdownのパースについてです。

作成したツールはGithubにあげてあります。 https://github.com/ayumax/MDToPPTX

また上記pptxを作成するツールの記事はこちらの記事を参照ください。

作成する環境

将来的にXamarinを使ったツールに組み込むことを考えていたため、.Net Standard 2.0のC#ライブラリとしてプロジェクトを作成しました。

全体としては以下の環境で作ってます

  • Windows10
  • Visual Studio 2017
  • C#
  • .Net Standard 2.0 Class library

どうやってMarkdownをパースするか

最初は自前で正規表現か何かを使ってパースする事を考えていましたが、Markdownの書式は意外に数が多いので何か便利なライブラリがないか探したところ以下のものを見つけました。

markdig

こちらのライブラリはGithubでBSD-Clause 2 licenseで公開されており、Nugetから取得できます。 Markdownをhtmlに変換するだけなら、そのままの利用でできますしhtml以外へも出力部分を自分で作成すれば可能な作りになってます。

markdigの通常の使用方法

Markdownをhtmlに変換するには以下の記述のみで可能です(githubのREADME.mdから抜粋)

var result = Markdown.ToHtml("This is a text with some *emphasis*");
Console.WriteLine(result);   // prints: <p>This is a text with some <em>emphasis</em></p>

parserとしてのmarkdigの利用

今回私のやりたかった事はMarkdownをパースして、PowerPointファイルを出力することですのでmarkdigのパーサーとしての機能のみ利用し、オリジナルの出力ができないか調査しました。

markdigの処理の仕組み

markdigを利用して独自の出力を行う場合にどのようにしれば良いかを調べるためプリセットされているHtmlRendererがどのように実装されているかを簡単に表したのが以下の図。

HtmlRendererの継承元をたどるとRendererBaseに行き着き、そこには変換時に使用するRender()メソッドと、Markdownのブロックとインラインを処理するためのObjectRenderersプロパティが定義してある。

Renderer

出力したいファイル別にRendererクラスを定義するようになっていて、デフォルト実装内ではHTMLファイルを出力するHtmlRendererと、正規化されたMarkdownテキストを出力するNormalizeRendererが実装してある。 上記クラス図の中間にあるTextRendererBaseはテキストファイルに出力するRendererの機能をまとめてあるクラス。

ObjectRenderers

パースされたMarkdownオブジェクトをどのような形式で出力するか定義する部分。 HtmlRendererのコンストラクタでは以下のように実装されている。

// Default block renderers
ObjectRenderers.Add(new CodeBlockRenderer());
ObjectRenderers.Add(new ListRenderer());
ObjectRenderers.Add(new HeadingRenderer());
ObjectRenderers.Add(new HtmlBlockRenderer());
ObjectRenderers.Add(new ParagraphRenderer());
ObjectRenderers.Add(new QuoteBlockRenderer());
ObjectRenderers.Add(new ThematicBreakRenderer());

// Default inline renderers
ObjectRenderers.Add(new AutolinkInlineRenderer());
ObjectRenderers.Add(new CodeInlineRenderer());
ObjectRenderers.Add(new DelimiterInlineRenderer());
ObjectRenderers.Add(new EmphasisInlineRenderer());
ObjectRenderers.Add(new LineBreakInlineRenderer());
ObjectRenderers.Add(new HtmlInlineRenderer());
ObjectRenderers.Add(new HtmlEntityInlineRenderer());            
ObjectRenderers.Add(new LinkInlineRenderer());
ObjectRenderers.Add(new LiteralInlineRenderer());

前半の定義ではブロック単位の出力を定義するオブジェクトが追加されており、後半の定義ではブロック内のインライン部分の出力定義がされたオブジェクトが追加されている。 パースされたMarkdownBlockオブジェクトには内部にさらにInlineオブジェクトが配置されているためRendererをBlockとInlineに分けることで処理の実装が書きやすくなっている。

独自のRendererを実装する際は、自分のパース対象のObjectRendererを作成して追加すればよい。 RendererクラスのRender()メソッドがコールされると、Blockオブジェクトに対応したObjectRendererのWrite()メソッドがコールされ、さらに再帰的にInlineオブジェクトに対応したObjectRendererのWrite()メソッドがコールされることで出力がされていく。

ObjectRenderer作成例

ObjectRendererのWrite()メソッドの第一引数にはRendererクラスの参照が渡され、第二引数にはパースされたブロックの参照が渡される。

ファイルへの出力に関するメソッドはRendererクラスにまとめておき、コールWrite()メソッド内からコールする仕組み。

例1.HeadingBlock

下記例ではヘッダーブロックのレベルがH1, H2の場合のみ出力オプションを変更している。

public class HeadingRenderer : PPTXObjectRenderer<HeadingBlock>
{
    protected override void Write(PPTXRenderer renderer, HeadingBlock obj)
    {
        // ヘッダーレベル毎に出力オプションを変更
        var _block = renderer.Options.Normal;
        switch (obj.Level)
        {
            case 1:
                _block = renderer.Options.Header1;
                break;
            case 2:
                _block = renderer.Options.Header2;
                break;
        }

        renderer.PushBlockSetting(_block);

        renderer.StartTextArea();

        renderer.WriteLeafInline(obj);
        renderer.PopBlockSetting();

        renderer.EndTextArea();
    }
}

例2.ListBlock

ListBlockでは箇条書きのリストが取得できるため、リストの項目数分Forループさせてさらに内部のオブジェクトを処理している。

public class ListRenderer : PPTXObjectRenderer<ListBlock>
{
    protected override void Write(PPTXRenderer renderer, ListBlock listBlock)
    {
        // リスト用書き込み設定を適用
        renderer.PushBlockSetting(renderer.Options.List);

        renderer.StartTextArea();

        // リスト項目数分まわす
        for (var i = 0; i < listBlock.Count; i++)
        {
            var item = listBlock[i];
            var listItem = (ListItemBlock)item;

            renderer.AddTextRow(new PPTXText()
            {
                // 数字タイプの箇条書きか、記号タイプの箇条書きかを設定
                Bullet = listBlock.IsOrdered ? PPTXBullet.Number : PPTXBullet.Circle
            });

            // 箇条書き1項目の内部を処理
            renderer.WriteChildren(listItem);

            renderer.WriteReturn();
        }

        renderer.EndTextArea();

        // リスト用書き込み設定を元に戻す
        renderer.PopBlockSetting();
    }

}

例3.CodeInline

CodeInineは上記2例とは異なりBlockではなくInlineになる。そのため文章の途中を処理している事を意識することがポイント(InlineRendererのWrite内では改行しないようにする必要がある気がする)。

public class CodeInlineRenderer : PPTXObjectRenderer<CodeInline>
{
   protected override void Write(PPTXRenderer renderer, CodeInline obj)
   {
        // インラインコード部のみフォントの設定を変更
        renderer.PushInlineSetting(renderer.Options.InlineCode);

        // インラインコード部を書き込み
        renderer.Write(obj.Content);

        // フォント設定を元に戻す
        renderer.PopBlockSetting();
    }
}

拡張機能

当初作成してから動作確認をすると、テーブル表記や打消し線の表記がパースされず未対応なのかと思ったら、拡張機能として対応されていました。

以下のような記述でテーブル表記とテキスト修飾記号の拡張を有効にしてパースできました。

var pipeline = new MarkdownPipelineBuilder()
                // テーブル拡張機能ON
                .UsePipeTables()
                // テキスト修飾記号の拡張ON
                .UseEmphasisExtras()
                .Build();

var document = Markdig.Markdown.Parse(markdown, pipeline);

まとめ

markdigを使用して実装することで、Markdown文字列のパースや、複雑なオブジェクトの入れ子構造の事は気にせずに、Block, Inline単位での出力フォーマットをどうするかということに考えを集中することができ、かなり実装時間は削減されました。

今後対応するBlockを増やす場合にも、今の実装への影響が少なくできそうです。

今回自分の実装した内容はPowerPointのファイルを出力するということでしたが、今回の記事の内容は他の使い方にも色々応用が効くと思います。


See also