直径1.5メートル

ひよっこエンジニアのちょっとしたメモ。主に備忘録。

【Rails】アップロードしたPDFから数枚画像に切り出したい【Grim】

何気に「このPDF、数ページだけ画像にして保存したい…」という時があると思うのです。 …なかったとしても、万が一発生した時にGrimというgemが使えそうだったのでメモ。

github.com

処理の流れ

想定している処理は、
1. PDFを保存&S3にアップロード
2. 保存したPDFから3枚画像に切り出す
3. 切り出した画像を保存&S3にアップロード
です。では早速。

モデル準備

f:id:mr_96:20170214231326j:plain ↑のイメージでモデルを作ります。
DocumentはPDFを持つように、PdfImageはcapture(=PDFから切り出した画像)を持つようにします。
Documentは複数枚の画像を持つので、has_manyで。
PDFと切り出した画像のアップロードはpaperclipを使います。 今回はpaperclipは入っている前提で進めます。

必要なファイルを生成

$ rails g model PdfImage
$ rails g scaffold documents

migrationファイルに必要なフィールドを追加。

# db/migrate/201702140001_create_documents.rb
class CreateDocument < ActiveRecord::Migration[5.0]
  def change
    create_table :douments do |t|
      t.string     :name
      t.attachment :pdf

      t.timestamps
    end
  end
end

# db/migrate/201702140000_create_pdf_images.rb
class CreatePdfImage < ActiveRecord::Migration[5.0]
  def change
    create_table :pdf_images do |t|
      t.attachment :capture
      t.references :document, foreign_key: true

      t.timestamps
    end
  end
end

migrationファイルができたら、コマンド実行。

$ RAILS_ENV=development rails db:migrate

モデルにattachmentのvalidationなど、必要項目を足していきます。

# app/models/pdf_image.rb
class PdfImage < ApplicationRecord
  has_attached_file :capture
  belongs_to :document

  validates_attachment_content_type :capture, :content_type => 'image/png'
end

# app/models/document.rb
class Document < ApplicationRecord
  has_attached_file :pdf
  has_many :pdf_images, dependent: :destroy

  validates_attachment_content_type :pdf, :content_type => 'application/pdf'
end

PDFの場合、白背景が透過されてしまい、白い背景が真っ黒になる場合もあるので、その時はhas_attached_fileのオプションにImageMagickでの変換指定することで回避できます。

has_attached_file :pdf, convert_options: {all: '-flatten'}

Grim処理のworkerを作成

PDFから画像を切り出して保存&S3でアップロードまでやろうとすると時間がかかるので、Workerで処理するようにします。

class CreatePdfImagesWorker
  include Sidekiq::Worker
  sidekiq_options queue: :create_pdf_images, retry: false

  def perform(document_id, target_file)
    document = Document.find(document_id)
    old_pdf_images = document.pdf_images.presence

    pdf = Grim.reap(target_file)
    # 切り出した画像の一時的な保存先を生成
    path = FileUtils.mkdir_p(Rails.root.join "tmp", "pdfs", document_id.to_s).first

    # 切り出す枚数(デフォルト3ページ分)
    page_num = 3
    page_num = pdf.count if pdf.count < 3

    page_num.times do |page|
      pdf_file_name = document.pdf_file_name.split(".")[0]
      pdf_image_path = "#{path}/#{pdf_file_name}_#{page}.png"
      # 指定ページを画像として切り出し
      if pdf[page].save(pdf_image_path)
        file = File.open(pdf_image_path)
        PdfImage.create(image: file, document_id: document_id)
        file.close
      end
    end

    # 古い画像の削除
    old_pdf_images.each{|pdf_image| pdf_image.delete} if old_pdf_images

    # アップが完了した作業ファイル、ディレクトリを削除
    FileUtils.remove_entry_secure path
    FileUtils.remove_entry_secure target_file
  end
end

controllerでworker呼び出し

controller内でやることは2つです。
1. PDFのtmpファイルを自分の扱いやすいところにコピーする
2. workerを呼び出す

1. PDFのtmpファイルを自分の扱いやすいところにコピーする
privateメソッドとして/tmp以下にあるPDFをコピーするメソッドを作ります。

# 画像切り出し用に、/tmp以下に配置されるPDFのtmpファイルをコピーしておく
# 戻り値はコピーしたtmp fileのpath
def copy_temp_pdf
  tempfile = document_params[:pdf]&.tempfile
  return unless tempfile

  pdf_dir = FileUtils.mkdir_p(Rails.root.join "tmp", "pdfs").first
  # PDFのtmpファイルをプロジェクトルートのtmp以下にコピー
  FileUtils.cp tempfile, pdf_dir
  "#{pdf_dir}/#{File.basename(tempfile)}" # File.join(pdf_dir, File.basename(tempfile)) でも同じ
end

document = Document.new 等で生成したインスタンスをsaveすると、PDFをアップロードする際にtmpファイルも消えてしまうので、saveする前にtmpファイルをコピーする copy_temp_pdf メソッドを呼び出します。
コピーしたファイルのパスが返ってくるので、変数に入れておきます。

2. workerを呼び出す
documentのidが必要になるので、workerの呼び出しはインスタンスsave後に行います。

class DocumentsController < ApplicationController
  # --- 諸々省略 ---
  def create
    @document = Document.new(document_params)
    target_file = copy_temp_pdf

    if @document.save
      CreatePdfImagesWorker.perform_async(@document.id, target_file) if target_file
      format.html
    end
  end
  # --- 諸々省略 ---
end

ざっとこんな感じでした。 ゴリゴリ気合いでやるか、複数枚アップロードするしかないのかなと思っていた矢先にこのGem。 やることが少ない分、使いやすい&わかりやすかったです。 tempファイルごにょごにょしてるあたり、もっと上手くやれたらなー。