「はてなブログライター」でタグのエスケープが効かない(追記あり)

はてなダイアリー終了ということで「はてダラ」を使っていた人が「はてなブログライター」に乗り換えたりするのかなーと思っていたら、バグ報告がありました。

HTMLソース等をエントリに書くためにエントリファイルの本文で <, > 等をエスケープして記述したのに、投稿するとエスケープが解除されてタグそのものになってしまうというバグものです。例えば &lt;br&gt; のように書いたのに、投稿すると BR タグそのものに化けてしまって改行してしまいます。

特殊文字エスケープ処理は atomutil が勝手にやってくれるはずなのにおかしいなと思って調べてみると、どうも Ruby の標準ライブラリの REXML の挙動が怪しい気がしてきました。

まず、atomutil が送信したデータを見るとエスケープの解除が既に行われていました。これで投稿を受信するはてな側の問題ではないことが確定しました。

送信データの XML 文書実体は Atom::Entry#to_s の戻り値がそのまま使われています。この値は REXML::Element オブジェクトの @elem をルート要素とする XML 文書実体としてシリアライズしたものです。

HatenaBlogWriter では Atom::Entry#content= にエントリファイルの本文テキストを無加工で渡しています。Atom::Entry#content= では渡されたテキストを含む Atom::Content オブジェクトを生成しますが、この時 Atom::Content#body= が呼び出されます。

HatenaBlogWriter ではこのメソッドに monkey patch を当てているので、body 設定後の @elem を出力してみたのですが、この時点ではエスケープ解除されていません。

コードを追いかけると、Atom::Entry#content= → Atom::Element#set → Atom::Element#add と呼び出されていって add の中で Atom::Content オブジェクトの elem を Entry の elem のツリーにコピーしている部分があり、エスケープの解除はこのコピーの際に発生していました。

具体的には、atomutil.rb:526です。

        element.text = value.elem.text unless value.elem.text.nil?

ここで valueAtom::Content オブジェクトです。value.elem.text は REXML::Text#text の戻り値になり、テキストノードに入っている素の文字列、つまり XML 文書内ではエスケープされている部分がエスケープされていない形になった文字列が取れるはずです。element.text への代入は REXML::Element#text= の呼び出しになり、REXML::Text#new に引数が渡されて無事同じ内容を持つテキストノードが Entry 側に生成・追加されるはずでした。

しかし、実際には REXML::Text#text の戻り値がエスケープが二重に解除された文字列になっていました。これは簡単なコードで再現できます。

require "rexml/document"
t = REXML::Text.new("&lt;")
puts t # => &amp;lt;
puts t.value # => <

t が表すテキストはあくまで「&lt;」であって「<」ではないはずなのですが… これは REXML のバグだと思います。

これを回避するには、一つは二重エスケープ解除を見越して文字実体参照の形をした文字列をあらかじめエスケープして渡すという方法はありますが、これだと将来 REXML のバグが修正されると二重エスケープされた文字列が投稿されてしまうことになり、うまくありません。

そこで上述のテキストノードのコピー処理を別のやり方でやることで回避することにしました。atomutil.rb:526 を以下のように置き換えます。

        element.text = REXML::Text.unnormalize(value.elem.get_text.to_s) unless value.elem.text.nil?

value.elem.get_text.to_s は元のテキストをエスケープ処理したものです。これを REXML::Text#unnormalize() でエスケープ解除して元のテキストに戻します。

これでうまくいくようなのですが、そもそもが REXML のバグなので atomutil の修正を要求するのも筋違いかもしれないなと思い、とりあえずまた monkey patch しました。。。

というわけで master では直っていると思います。気になる方は試してみてください。

既に投稿済みのエントリがこのバグのせいで壊れている場合は update サブコマンドを使って強制的にエントリを更新してみてください。

追記: 2018-09-04

REXML のバグの件、ruby 本家にバグレポしたところ互換性が壊れるので修正できないということで、かわりに atomutil の方の修正を提案されました。*1

そこで atomutil の方に持っていったところ早速対応していただいて atomutil 0.1.5 がリリースされました。0.1.5 ではエントリの content に UTF-8 の文字列を渡すとエラーになる問題も修正されています。

これに合わせて HatenaBlogWriter でもこのあたりを回避するために入れていた monkey patch を全部削除しました。v0.7 からは atomutil 0.1.5 以降が必須になります。どうしても atomutil 0.1.4 を使用する必要がある場合は v0.6 を使用してください。

バグ報告に迅速に対応してくださった kou さん、lyokato さん、どうもありがとうございました!

*1:ちなみについでに見つけた REXML::Text#clone のバグの方は速攻で修正していただきました。https://bugs.ruby-lang.org/issues/15058