Rails(Ruby)でPDFを作成、圧縮、S3にアップロード

# TL;DR

```rb
# PDFをインラインで作成
require 'wicked_pdf'
pdf = WickedPdf.new.pdf_from_string(
  "Lorem ipsum ロレム・イプサム 呂礼無・維風沙夢" * 500,
  return_file: true,
)


# Tempfileの作成&圧縮
require 'tempfile'
require 'zlib'

tf = Tempfile.open(['foo', '.pdf.gz'], binmode: true)
Zlib::GzipWriter.wrap(tf, Zlib::BEST_COMPRESSION) do |gz|
  gz.print(pdf)
end


# AWS S3アップロード
require 'aws-sdk'

client = Aws::S3::Client.new(
  access_key_id: ENV['AWS_ACCESS_KEY_ID'],
  secret_access_key: ENV['AWS_SECRET_KEY_ID'],
  region: 'ap-northeast-1',
)

client.put_object(
  bucket: "bucket_name",
  content_encoding: "gzip",
  content_type: "application/pdf",
  key: "pdf/foo.pdf.gz",
  body: tf,
)

tf.close!


# Presigned URLでダウンロード
require 'aws-sdk'

bucket = Aws::S3::Resource.new(
  access_key_id: ENV['AWS_ACCESS_KEY_ID'],
  secret_access_key: ENV['AWS_SECRET_KEY_ID'],
  region: 'ap-northeast-1',
).bucket('bucket_name')

obj = bucket.object("pdf/foo.pdf.gz")

obj.presigned_url(
  :get,
  response_content_disposition: 'attachment; filename="FooBar.pdf"',
)
```

# はじめに
令和5年10月より、[インボイス制度](https://www.nta.go.jp/taxes/shiraberu/zeimokubetsu/shohi/keigenzeiritsu/invoice_about.htm)が始まります。おかげさまで開発者として領収書の保存についていろんな方法を調査・考察していました。

# 調査で分かったこと
## PDFのInline出力
`WickedPdf.new.pdf_from_string()`などで出力できます、使い方については[Wicked PDFのReadme](https://github.com/mileszs/wicked_pdf#super-advanced-usage)で詳しく書いていたので、ここでは割愛します。

特筆したいのは、`pdf_from_string()`のデフォルトの挙動をよくよく見ると、[バイナリのStringを返しています](https://github.com/mileszs/wicked_pdf/blob/ced04ac579f33862df492643568a29a4b4b2dd78/lib/wicked_pdf/tempfile.rb#L22)、そのやり方だと、もしファイルサイズが大きい場合、必要以上にたくさんメモリ消耗する可能性が生じています。
なので`pdf_from_string('Foo', return_file: true)`のようにフラグを立てば、Tempfileの引用が返してくれます。この引用があれば、S3にアップできます。

```rb
pdf = WickedPdf.new.pdf_from_string(
  "Lorem ipsum ロレム・イプサム 呂礼無・維風沙夢" * 500,
  return_file: true,
)
=> #<File:/var/folders/1r/npvfcq595hs4fvhwwwnjj73472pvds/T/wicked_pdf_generated_file20230111-94541-6jmf8s.pdf>

# S3 client事前設定済みとして
client.put_object(
  bucket: 'bucket_name',
  content_type: "application/pdf",
  key: "pdf/foo.pdf", body: pdf
)
=> #<struct Aws::S3::Types::PutObjectOutput....
```
![Cursor_と_subsc-at-development_-_S3_bucket_001.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/87438/a6ce899f-642b-028b-0740-eb2ea6cfec6e.png)

![Cursor_と_foo_pdf.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/87438/09b5c7af-4fc3-fe38-d2a2-9ca0f4133333.png)


# GZIP圧縮してからアップロード
インボイス制度で大量に請求書・領収書を保管する場合、容量を減らすことはサーバー原価の削減に直結していますので、圧縮を試みます。

## [Tempfile](https://docs.ruby-lang.org/ja/latest/class/Tempfile.html)を利用する場合

```rb
pdf = WickedPdf.new.pdf_from_string("Lorem ipsum ロレム・イプサム 呂礼無・維風沙夢" * 500, return_file: true)

# Tempfileの受け皿を作成
tf = Tempfile.open(['gzip', '.gz'], binmode: true) # `binmode: true`がないと失敗します
=> #<File:/var/folders/1r/npvfcq595hs4fvhwwwnjj73472pvds/T/gzip20230111-94541-1i0y35y.gz>

# ZlibでpdfのfileIOと受け皿のTempfileに繋ぐ
chunk = 1024 * 1024
Zlib::GzipWriter.wrap(tf, Zlib::BEST_COMPRESSION) do | gz | # 9 == Zlib::BEST_COMPRESSION
  gz << pdf.read(chunk) until pdf.eof?
end
=> nil

# S3 client事前設定済みとして
client.put_object(
  bucket: 'bucket_name',
  content_encoding: 'gzip', # 設定すれば、DLする際、S3が勝手に解凍してくれる
  content_type: "application/pdf",
  key: "pdf/foo.pdf.gz", body: tf, # 先ほど設定した受け皿のTempfileをアップロード
)

# 最後に２つのTempfileをclose
pdf.close!
=> true
tf.close!
=> true
```

![Cursor_と_subsc-at-development_-_S3_bucket.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/87438/8daa646e-6bf2-c510-c5ae-c50cc74352b1.png)

Gzipで元のpdfファイルの7割以下まで圧縮できました。

![Cursor_と_subsc-at-development_-_S3_bucket002.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/87438/09824240-22dc-f8eb-024c-5560fc9e6fef.png)
![subsc-at-development_-_S3_bucket.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/87438/187bcf0c-7066-d61f-5b83-e1f394c446ea.png)

面白いことに、S3の管理画面上でダウンロードすると、その場で解凍され、拡張子も変化し、`foo.pdf`としてダウンロードされます。

![Cursor_と_subsc-at-development_-_S3_bucket.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/87438/22cab6c3-be15-f8ea-7a8f-47cdf87adf9a.png)
![Cursor_と_foo_pdf_gz.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/87438/d030b8ac-2939-a15e-9d37-3355c6305de0.png)

そしてS3の管理画面で「署名付きURL」を生成したら、ファイル名こそ変わってないが、解凍されたPDFとしてプレビューできます。


## ダウンロード用署名付きURLの生成
設定して解凍されたにもかかわらず、名前に`.gz`が入っているのは気持ち悪いので、ファイル名してしてダウンロードすることも可能です。

```rb
# S3 Bucketが設定済みとして
obj = bucket.object("pdf/foo.pdf.gz")
obj.presigned_url(
  :get,
  response_content_disposition: 'attachment; filename="FooBar.pdf"',
)
=> "https://foobar.s3.ap-northeast-1.amazonaws.com/pdf/foo.pdf.gz?response-content-disposition=attachment%3B%20filename%3D%22FooBar.pdf%22&X-Amz-Algorithm=..."
```

![foo_pdf_gz.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/87438/7b4ee51e-dc6a-556b-7d28-d75444224053.png)

上記メソッドで生成したURLでアクセスすると、`FooBar.pdf`としてダウンロードされます。


## [余談] [StringIO](https://docs.ruby-lang.org/ja/latest/class/StringIO.html)を使って圧縮する方法
安定性やメモリ使用などについてはまだ未考察なので、保証いたしません。

```rb
io = StringIO.new
io.binmode # 重要

gz = Zlib::GzipWriter.new(io)
gz.write(pdf)
# もしFileIOを使ったの場合は 
# gz << pdf.read until pdf.eof?
gz.close # Closeしないと、書き込みが完了しません

client.put_object(
  bucket: "bucket_name",
  content_encoding: "gzip",
  content_type: "application/pdf",
  key: "foo.pdf.gz",
  body: io.string,
)
```

# 参考

- [S3 + Content-Encoding メタデータについて](https://oji-cloud.net/2021/03/12/post-6005/#toc_id_4)
- [How to write a Tempfile as binary](https://stackoverflow.com/questions/60830330/how-to-write-a-tempfile-as-binary)
- [Amazon S3の署名付きURL発行の際に別名でファイルをダウンロードさせるようにする](https://dev.classmethod.jp/articles/s3_signed_url_alias_file_dl/)
- [S3 Presigned URL Multiple Content Disposition Headers](https://stackoverflow.com/questions/59684182/s3-presigned-url-multiple-content-disposition-headers)
- [Zlib::GzipReader/WriterとStringIOをつかってファイルを作らずGzip圧縮なデータを扱う](https://shuzo-kino.hateblo.jp/entry/2015/04/11/234814)
- [Content-Type, Content-Dispositionの役割](https://www.wakuwakubank.com/posts/799-it-content-type-content-disposition/)