Rails の send_data で Windows 用の zip ファイルを送る

Windows 用にファイル名の文字コードを CP932 にしたいわけですが、ハマりどころが多かったのでメモです。
次のようなコードで send_data (not send_file) に指定する zip データを生成可能です。

require 'zip'
require 'zip/version'
require 'open-uri'

def image_data
  @image_data ||= open('http://dev.abicky.net/img/favicon.png').read
end

puts "rubyzip version: #{Zip::VERSION}"

if Zip::VERSION < '1.2.0'
  module Zip
    class Deflater
      module EncodingSetter
        def initialize(*args)
          super
          @buffer_stream.set_encoding(@output_stream.external_encoding)
        end
      end
      prepend EncodingSetter
    end
  end
end

def zip_data_using_write_buffer
  io = StringIO.new
  io.set_encoding(Encoding::CP932)
  Zip::OutputStream.write_buffer(io) { |out|
    out.put_next_entry('テスト.png'.encode(Encoding::CP932))
    out.write(image_data)
  }.string
end

解説

Zip::OutputStream.write_buffer には Encoding::CP932 の StringIO を渡す

そうしないと “local header size changed (43 -> 40) (Zip::Error)” のようなエラーが出ます。
これは、StringIO の文字コードは UTF-8 であり、CP932 のファイル名を指定しても勝手に UTF-8 に変換されてしまうことが原因です。それによって、StringIO の情報から出したヘッダーサイズと、与えられたファイル名(CP932 のファイル名)のバイト数から出したヘッダーサイズが異なるようになり、エラーになります。

cf. https://github.com/rubyzip/rubyzip/blob/v1.2.0/lib/zip/entry.rb#L128

[1] pry(main)> io = StringIO.new; io << 'あ'; io.tell
=> 3
[2] pry(main)> io = StringIO.new; io.set_encoding(Encoding::CP932); io << 'あ'; io.tell
=> 2

rubyzip のバージョンが 1.2.0 より前の場合は Zip::Deflater にパッチを当てる

そうしないと “incompatible character encodings: Windows-31J and UTF-8 (Encoding::CompatibilityError)” のようなエラーが出ます。
これは、Zip::Deflater が Zlib::Deflate#deflate の結果を独自に用意した StringIO に格納しているせいで、UTF-8 に変換されてしまうことが原因です。1.2.0 は実装が変わっているのでこの問題は起きません。

cf. https://github.com/rubyzip/rubyzip/blob/v1.1.7/lib/zip/deflater.rb

もっとシンプルにやればいいんじゃないの?

パフォーマンスが求められないのであれば、次のような書き方が可能です。この場合、zip ファイルが作成されるので、send_data ではなく send_file を使うと思いますが。

def zip_data_using_open
  Tempfile.open('') do |tmp|
    Zip::OutputStream.open(tmp) do |out|
      out.put_next_entry('テスト.png'.encode(Encoding::CP932))
      out.write(image_data)
    end
    File.read(tmp)
  end
end