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タグ処理メソッドが呼び出されてしまうし、
そこで直接関数を定義するのではなく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}"} ........