Markdown ブロックの WikiWikiWeb 方式での書式変換 (その2)

WikiWikiWeb 方式で、 行単位でのマーク・スタックの変化から Markdown のブロック要素の入れ子構造の追跡を前回に試みました。 マーク・スタックを作るのは物理行単位とし、 すべての行でマーク・スタックを作っていたのですけど、 Markdown には空行とインデント揃えをさぼった不精行 (lazy line) があります。 そこではマーク・スタックが不完全になるので、 直前の完全なマーク・スタックをコピーすることで、 レイアウト情報を行から行へ渡していきました。

Wiki Creole 規格でも、 物理行単位ではなく、 段落等の論理単位でマーク・スタックを作ることをふまえると、 Markdown でも一連のまとまった複数の行からなる論理単位で処理を進めていった方が良いでしょう。 そこで、 今回は、 完全なレイアウト情報を行頭に持つ開始行とその直後に連なる不精行類と空行を論理単位として扱うように動作を変更してみます。

マーク・スタックは論理単位の開始行でだけ作ります。 その後に連なる行ではインデント・ブロックのように同じインデントになっているかどうかをチェックすることもあれば、 不精行のようにインデント分のレイアウト情報が存在するとそれを無視することもあります。 ブロックの種類によって、 どのように行頭のレイアウト情報を扱うべきかが変わるため、 今回の方が Markdown の行の扱いの場合分けがやりやすくなります。

なお、 今回の処理の論理単位は、 CommonMark 規格の葉ブロックに対応するよう選んでいます。 異なる点は、 コンテナ・ブロックの行頭マークも葉ブロックごとに解釈するところです。 また、 setext ヘディングの扱いと不精行の打ち切り条件を CommonMark 規格ではなく、 John Grubber の Markdown 実装に合わせています。 さらに、 HTML ブロックは Markdown 実装に近いままで、 CommonMark 規格にしていません。

前回と違い、 マーク・スタックは葉ブロック単位でしか作らないので、 直前の葉ブロックのマーク・スタックと現在調べている葉ブロックのそれの 2 つに減ります。

class Block
  def initialize(string)
    string.chomp!
    string << "\n\n"
    @src = MiniStrScanner.new(string)
    @nest = []      # 直前の葉ブロックのマーク・スタック
    @mark = []      # 現在の葉ブロックのマーク・スタック
    @inline = []    # 現在の葉ブロックのインライン
    @bol_out = true
  end
end

convert を葉ブロック単位でのループに変えます。 Markdown 実装に合わせて、 HTML ブロックとリファレンス・リンク定義は行頭にしか記述できないとしています。 CommonMark 規格のようにコンテナ・ブロックの中でもこれらを記述できるようにすることは、 今後の課題として残しておきます。次にコンテナ・ブロックのマークとインデントからマーク・スタックを作ります。 そして、 葉ブロックのマークをマーク・スタックに加えます。 インライン配列に文字列範囲を記録して、 開始行の処理が終わります。

開始行の後に続く一連の行の処理を続けます。 次の行が下線行のときは setext ヘディングにします。 インデント・コード・ブロックのときは、 コード・ブロックの終わりまでインライン配列に文字列範囲を記録していきます。 平文のときは不精行の終わりまでインライン配列に記録します。 今回も Markdown 実装に合わせているので、 リストの途中の開始マークの違いを無視するようにマークを上書きします。 次の論理単位との間にある空行を読み飛ばします。 葉ブロックの終わりに到達しているので、 ここで葉ブロックを HTML のタグ付きで出力します。

入力テキストの末尾に達したら、 コンテナ・ブロックを閉じます。 これで変換は終わりです。

  def convert()
    @src.scan(%r/\G(?:[ ]*\n)+/)
    @nest = []
    while not @src.eos?
      @mark = []
      @inline = []
      next if block_backtick()
      next if html_block_element()
      next if reference_link_definition()
      lazyness = container_block_mark()
      leaf_block_mark()
      inline_push(@src)
      setext_heading()
      if :BLANK == @mark[-1]
        @mark.pop
      elsif ' ' == @mark[-1]
        indented_code_block()
      elsif :PLAIN == @mark[-1]
        lazylines(lazyness)
        @mark[-1] = :p
        if :UL == @mark[-2] || :OL == @mark[-2]
          @mark.pop
        end
      end
      skip_blank_lines()
      markup_block(@mark)
    end
    close_container(@nest, 0)
  end

バック・チックによるコード・ブロック、 HTML ブロックの変換は前回とほとんど同じです。 追加したのは末尾の空行の読み飛ばしだけです。 HTML ブロックの処理部分を載せておきます。

  def html_block_element()
    @src.scan(HTML_BLOCK) or return false
    markup_block(@mark)
    print_block @src.string[@src.begin(0) ... @src.end(0) - 1]
    skip_blank_lines()      # 末尾の空行を読み飛ばします
    true
  end

リファレンス・リンク定義も前回とほぼ同じです。 マーク・スタックの書き戻しが不要になったので省き、 末尾の空行の読み飛ばしを追加しています。

  def reference_link_definition()
    @src.scan(REFLINK_DEF) or return false
    reftitle = @src[4] || @src[5] || @src[6]
    refuri = @src[2] || @src[3]
    refsign = @src[1].gsub(%r/[ ]+/, ' ').downcase
    skip_blank_lines()
    true
  end

前の葉ブロックのマーク・スタック @nest と照らし合わせながらコンテナ・ブロックのインデントを処理します。 ここでは、 リスト項目のインデントに対応する空白 4 つと、 引用マークを読みます。

  def indentation()
    lazyness = :DEFAULT
    same, i = true, 0
    while true
      sym = down(@nest[i])
      if same && (:ul == sym || :ol == sym) && @src.scan(%r/\G[ ]{4}/)
        @mark << sym
        lazyness = :LIST
      elsif @src.scan(%r/\G[ ]{0,3}>[ ]?/)
        @mark << '>'
        if '>' != sym then same = false end
      else
        break
      end
      i += 1
    end
    lazyness
  end

ブロック・マークの英大文字・英小文字を変換するメソッド名を前回から変更しました。

  def up(sym) :ul == sym ? :UL : :ol == sym ? :OL : sym end
  def down(sym) :UL == sym ? :ul : :OL == sym ? :ol : sym end

インデント読み取りを使ってコンテナ・ブロックのマークを読み込みます。 このメソッドは、 リスト項目マークの読み込みをおこないます。 リスト項目マークの入れ子には対応していません。 これも Markdown 実装に合わせて、 リストの中ではリスト項目マークの区別をしないように直前のリスト項目のマークで上書きします。

  #  "  *   *   * a\n"  =>  "<ul>\n<li>*   * a\n"
  #  "      * b\n"          "<ul>\n<li>* b</li>\n</ul>\n</li>\n</ul>\n"
  #
  #  "  *   *   * a\n"  =>  "<ul>\n<li>*   * a\n"
  #  "          * b\n"      "* b</li>\n</ul>\n"
  def container_block_mark()
    lazyness = indentation()
    i = @mark.size
    if @src.match?(%r/\G[ ]{0,3}(?:(?:[*][ ]*){3,}|(?:[-][ ]*){3,})$/)
      # 先読みで hr 要素マークを除外
    elsif @src.scan(%r/\G[ ]{0,3}[*+-][ ]+(?=\S)/)
      @mark << :UL
      lazyness = :LIST
    elsif @src.scan(%r/\G[ ]{0,3}\d+[.][ ]+(?=\S)/)
      @mark << :OL
      lazyness = :LIST
    end
    if (:UL == @mark[-1] || :OL == @mark[-1]) && i < @nest.size
      sym = up(@nest[i])
      if :UL == sym || :OL == sym
        @mark[-1] = sym
      end
    end
    lazyness
  end

葉ブロックのマークを読み込みます。 引用ブロックマークとリスト項目マーク以外が葉ブロックのマークです。

  def leaf_block_mark()
    if @src.scan(%r/\G[ ]*$/)
      @mark << :BLANK
    elsif @src.scan(%r/\G[ ]{4}/)
      @mark << ' '
    elsif @src.scan(%r/\G[ ]{0,3}(?:(?:[*][ ]*){3,}|(?:[-][ ]*){3,}|(?:_[ ]*){3,})$/)
      @mark << :hr
    elsif @src.scan(%r/\G[ ]{0,3}(\#{1,6})[ ]+(?=\S)/)
      @mark << "h#{@src.end(1) - @src.begin(1)}".intern
    else
      @src.scan(%r/\G[ ]*/)
      @mark << :PLAIN
    end
  end

setext ヘディングは、 相変わらず Markdown 実装に近いままで CommonMark 規格には対応していません。 今回は、 コンテナ・ブロックへの入れ子を許すことにして、 setext ヘディングをリストの先頭要素にすることができるようにしてあります。 また、 setext ヘディングの下線行の下には空行を要求しています。 空行がないときは、 リスト外の段落ではインラインに、 リスト内の段落では一重線を hr 要素へ変換します。

  def setext_heading()
    if :PLAIN == @mark[-1]
      adv = @src.branch
      i = indent_level(adv, @mark)
      if i == @mark.size - 1 && adv.scan(%r/\G[ ]{0,3}(?:(=)=*|--*)[ ]*\n/)
        h = adv.begin(1) ? :h1 : :h2
        i = indent_level(adv, @mark)
        if (i + 1 ... @mark.size).all?{|j| '>' != @mark[j] } && adv.scan(%r/\G[ ]*\n/)
          @mark[-1] = h
          @src.merge(adv)
        end
      end
    end
  end

setext ヘディングかどうかは先読みしてみないとわからないため、 MiniStrScanner オブジェクトを複製できるようにしてあります。 branch メソッドでスキャナ・オブジェクトを複製し、 merge メソッドでスキャン・ポインタを取り込みます。

class MiniStrScanner
  attr_accessor :string, :pos, :last_matched
  def initialize(string, pos=0) @string, @pos, @last_matched = string, pos, nil end
  def eos?() @pos >= @string.size end
  def bol?() 0 == @pos || "\n" == @string[@pos - 1] end
  def match?(regexp) @last_matched = regexp.match(@string, @pos) end
  def [](i) @last_matched && @last_matched[i] end
  def begin(i) @last_matched&.begin(i) end
  def end(i) @last_matched&.end(i) end
  def branch() self.class.new(@string, @pos) end
  def merge(scanner) @pos = scanner.pos; self end

  def scan(regexp)
    @last_matched = regexp.match(@string, @pos) or return nil
    @pos = @last_matched.end(0)
    self
  end
end

(その3) に続きます。