NokogiriでXMLをガチParseするためのメタプログラミング

要約

RubyでHTMLからTeXへのトランスレータを書いた。
NokogiriのNokogiri::XML::SAX::Documentあたりを使うのが便利そうに感じたが、実際にやるとソースコードが崩壊した。
SAXではなくDOMを用いて階層構造を再帰で辿ったほうがいい。さらにメタプログラミングを用いると割と簡潔な記述にできる。

原因

RubyでのXML操作にはデファクトスタンダードとなりつつあるNokogiri。
HTMLから特定のタグを抽出して……のようなお手軽パースには大変快適なんですが、XMLの全部のタグにアクションを起こすような本格的にパースするとき、すごくやりづらい気がする。そもそもググッてもロクなexampleがでてこない

しょうがないので試行錯誤して、まずNokogiriのSAXを使った。
SAXはXMLを「要素の始まり」、「要素の終わり」、「テキスト」の3つにわけ、それぞれで定義した関数を呼ぶ、

# <title>Hello</title><br>World 
#=> \title{Hello}\par{}World

class HTMLDoc < Nokogiri::XML::SAX::Document
  def start_element name, attrs = []
    case name
    when 'title'
      @tex << '\\title{'
    when 'br'
      @tex << '\\par{}'
    end
  end

  def end_element name
    case name
    when 'title'
      @tex << '}'
    end
  end

  def characters str
    @tex << str
  end
end


SAXはシンプルでお手軽であるが、要素が増え、扱う内容が複雑になると、「要素の開始」と「要素の終了」、「要素内のテキスト」の処理をそれぞれ別の位置で行うことによるコードの分断化が激しくなる。
たとえばこんなふうに、

# aタグの処理の流れを追ってみよう!

class HTMLDoc < Nokogiri::XML::SAX::Document
  def initialize t
    @t = t
    ...
  end

  def start_element name, attrs = []
    case name
    when 'set'
      set_option attrs
    when 'title'
      @mode.push :title
    when 'author'
      @t.body << "\n\n\\hfill "
    when 'br'
      @t.body << '\\par{}'
    when 'p'
      @t.body << "\\vspace{1zw plus .1zw minus .4zw}\n\n"
    when 'hr'
      @t.body << "
        \\vspace{1zw plus .1zw minus .4zw}\n\n
        \n\n\\noindent
        \\hfil
        \\rule{#{@t.textwidth_consider_column * 0.7}pt}{.01zw}
        \\hfill\n\n"
    when 'a'
      @mode.push :a_link
      @t.body << begin_a(attrs)
    ........
  end

  def end_element name
    case name
    when 'title'
      @mode.pop
    when 'author'
      @t.body << "\n\n"
    when 'rb'
      @t.body << '}'
    when 'rt'
      @t.body << '}'
    when 'rp'
      @mode.pop
    when 'a'
      @mode.pop
      @t.body << end_a
    ....
  end


  def characters str
    case @mode.last
    when :ignore
      return
    when :title
      ....
    else
      ....
    end
  end

  def begin_a attrs
    url = ''
    attrs.each_slice(2) do |key, value|
      case key
      when 'href'
        @a_url = value
      end
    end
    ...
  end

  def end_a
    "\
\\special{color pop}\
\\special{pdf:eann}"
  end

  .......
end

SAXからDOMへ

DOMを再帰的にたどるパースを行うようにした。タグと処理のディスパッチがめんどうそうなので__send__でタグ名のメソッドを呼び出すことにする。

class TransformHTMLToTex
  def initialize t=nil
    @t = t
    @zenkaku_kagikakko = false
    @force_kansuji = false
  end

  def parse n
    if n.kind_of?(Nokogiri::XML::NodeSet)
      n.map do |node|
        _parse node
      end.join('')
    else
      _parse n
    end
  end

  def _parse node
    if node.kind_of?(Nokogiri::XML::Text)
      text(node.content)
    elsif node.kind_of?(Nokogiri::XML::Node)
      begin
        @node = node
        @recur = proc{self.parse node.children}
        self.__send__('tag_' + node.name.downcase)
      rescue NoMethodError
        self.parse node.children
      end
    else
      raise "Cannnot parse. Unknown Node: #{node.class.inspect}"
    end
  end

  def recur
    @recur.call
  end

  def text str
    to_kansuji!(str) if @force_kansuji
    tex_escape!(str)
    str.gsub! //, '{\makebox[1zw][r]{「}}' if @zenkaku_kagikakko
    if @hyperlink
      a_text str
    else 
      str
    end
  end

  def title
    h = @t.fontsize / 2.0
    @node.content.each_char.map do |char|
      "\\raisebox{0pt}[#{h}pt][#{h}pt]{\\Huge\\mcfamily\\bfseries #{char}}\n"
    end.join('')
  end
  def author() "\n\n\\hfill #{yield}\n\n"; end
 
  def rb() "\\kana{#{yield}}"; end
  def rt() "{#{yield}}"; end
  def rp() ""; end

  def br() '\\par{}'; end
  def hr
    "\
\\vspace{1zw plus .1zw minus .4zw}\n\n
\n\n\\noindent
\\hfil
\\rule{#{@t.textwidth_consider_column * 0.7}pt}{.01zw}
\\hfill\n\n"
  end

  def p() "\\vspace{1zw plus .1zw minus .4zw}\n\n#{yield}"; end
  
  .......

TransformHTMLToTex.new.parse(Nokogiri::HTML(url))
のようにして呼び出せる。SAXの場合と違い、それぞれのタグへの処理が一箇所にまとまっている。
またタグを処理するメソッドに、子タグの処理のやり方をBlockでわたしているため、子タグを処理する/しないをyieldを呼ぶかで制御できる。

名前の衝突の解決、内部DSLチックに

このままだとクラス内の他のメソッドと名前が衝突してしまっている。クラスないで
p debug
とかくとpタグ処理メソッドが呼び出されてしまうし、
みたいにHTMLから変なメソッドが呼び出されてしまうかもしれない。

そこで直接関数を定義するのではなくdefine_methodでtag_title, tag_aのようなメソッド名を定義することにする。
define_methodではyieldが使えなくなることに悩んだが、インスタンス変数でブロックを渡すことにした。

class TransformHTMLToTex
  def self.tag name, &block
    define_method('tag_' + name.to_s, block)
  end

  def initialize t=nil
    @t = t
    @zenkaku_kagikakko = false
    @force_kansuji = false
  end

  def parse n
    if n.kind_of?(Nokogiri::XML::NodeSet)
      n.map do |node|
        _parse node
      end.join('')
    else
      _parse n
    end
  end

  def _parse node
    if node.kind_of?(Nokogiri::XML::Text)
      text(node.content)
    elsif node.kind_of?(Nokogiri::XML::Node)
      begin
        @node = node
        @recur = proc{self.parse node.children}
        self.__send__('tag_' + node.name.downcase)
      rescue NoMethodError
        self.parse node.children
      end
    else
      raise "Cannnot parse. Unknown Node: #{node.class.inspect}"
    end
  end

  def recur
    @recur.call
  end

  def text str
    to_kansuji!(str) if @force_kansuji
    tex_escape!(str)
    str.gsub! //, '{\makebox[1zw][r]{「}}' if @zenkaku_kagikakko
    if @hyperlink
      a_text str
    else 
      str
    end
  end

  tag :title do
    h = @t.fontsize / 2.0
    @node.content.each_char.map do |char|
      "\\raisebox{0pt}[#{h}pt][#{h}pt]{\\Huge\\mcfamily\\bfseries #{char}}\n"
    end.join('')
  end
  tag(:author) {"\n\n\\hfill #{recur}\n\n"}

  tag(:rb) {"\\kana{#{recur}}"}
  tag(:rt) {"{#{recur}}"}
  tag(:rp) {""}

  tag(:br) {'\\par{}'}
  tag :hr do
    "\
\\vspace{1zw plus .1zw minus .4zw}\n\n
\n\n\\noindent
\\hfil
\\rule{#{@t.textwidth_consider_column * 0.7}pt}{.01zw}
\\hfill\n\n"
  end

  tag(:p) {"\\vspace{1zw plus .1zw minus .4zw}\n\n#{recur}"}

  ........