やなぎにっき

学んだことの記録

画面上のDOM要素を画像に変換してサーバーに保存する 【Rails6】

f:id:yana_g:20211220205329p:plain

この記事はフィヨルドブートキャンプ Part 1 Advent Calendar 2021の記事です。

フィヨルドブートキャンプ Part 1 Advent Calendar 2021 - Adventar

フィヨルドブートキャンプ Part 2 Advent Calendar 2021 - Adventar

昨日の19日目は

でした。

はじめに

わたしは本日フィヨルドブートキャンプの課題として個人開発した TwiCode というサービスをリリースしました。

twicode.herokuapp.com

yana-g.hatenablog.com

サービスの概要や作った背景、苦労したことや工夫したことはリリースブログに書いたので、今回はサービスの肝となる実装について技術的な話をしたいと思います。

TwiCodeはテキストで入力したソースコードシンタックスハイライトを適用したものを画像に変換し、その画像をサーバー側で保存しています。その画像はURLをツイートすることでTwitterカードに画像を表示することができます。

画像にして画面上に表示するだけであればJavaScriptのみで実装できますが、OGPに表示するには画像にするだけでなくサーバーに保存する必要があります。

DOM要素を画像化してサーバーに保存する処理は他に参考にできる記事がなく苦労したので、簡単ではありますが紹介していきます。

処理のおおまかな流れです

  • JavaScript

    1. 保存ボタンを押したらフォーム送信の前に処理を走らせる
    2. 画面上のDOM要素をcanvas形式に変換する
    3. canvasデータをデータURLに変換する
    4. データURLをRailsに渡す
  • Rails

    1. パラメーターで受け取ったデータURLをActiveStorageに保存する

開発環境・使用ライブラリ

  • Rails 6.1.4
  • Ruby 3.0.2
  • ActiveStorage
  • html2canvas

今回はhtml2canvas というライブラリを使ってDOMの要素を画像(canvas)変換していきます。 html2canvasを使うと、下記のサンプルのボタンを押すとHello world!の要素が画像になって表示されるようなことができます。便利!

See the Pen Untitled by yana-gi (@yana-gi) on CodePen.

事前準備

今回はscaffoldで自動で作成されたformのtextarea部分を画像にして、サーバーに保存する処理を作ってみます。

rails new して scaffoldする

サンプル用のアプリを作ります。

rails new して text型のbody を持つモデルをscaffoldします。モデル名はなんでもいいですが、今回はcodeで作成していきます。

$ rails new
$ rails g scaffold Code body:text

html2canvasをインストール

$ yarn add html2canvas

Active Storage をインストール

$ rails active_storage:install
$ rails db:migrate

app/models/code.rb

class Code < ApplicationRecord
  has_one_attached :image # 追加
end

Railsでhtml2canvasを動かしてみる

いきなり保存する処理を作るのは難しいので、試しに要素の画像を画面上に表示するボタンを作ってみます。

app/views/codes/_form.html.erb formテンプレートにボタンを追加します。

<input type="button" value="button" class="my-button">

app/javascript/packs/application.js

/app/javascript/src/code.js を読み込めるようにします

require("../src/code");

/app/javascript/src/code.js

ボタンが押されたらhtml2canvasが#code_bodyの要素をcanvasに変換し、子ノードの最後に表示するようにします。

import html2canvas from "html2canvas";

document.addEventListener('DOMContentLoaded', () => {
    const myButton = document.querySelector('input.my-button');
    myButton.addEventListener('click', function () {
        html2canvas(document.querySelector("#code_body")).then(canvas => {
            document.body.appendChild(canvas)
        });
    });
})

これでボタンを押すとformのtextareaが画像として表示する処理できました✌️

Image from Gyazo

画面上のDOM要素をサーバーに保存する

事前準備が終わりhtml2canvasの動かし方がわかったところで、実装に入っていきます。

JavaScript側の処理

  1. 画面上のDOM要素を取得する
  2. submitボタンが押される
  3. submit後の処理をキャンセルする
  4. 要素をcanvas形式に変換する
  5. canvasデータをデータURLに変換する
  6. データURLを隠しフィールドにsetする
  7. submit処理を実行する

Rails側の処理

  1. データURLを受け取る
  2. データURLからActiveStorageにattachする

JavaScript

app/javascript/src/code.js

import html2canvas from "html2canvas";

document.addEventListener('DOMContentLoaded', () => {
    // [1] 画面上のDOM要素を取得する
    const form = document.querySelector("form");
    const code_body = document.querySelector("#code_body")
    const hiddenImageDataUrl = document.querySelector("#code_image_data_url");

    // [2] submitボタンが押される
    form.addEventListener("submit", function (event) {
        // [3] submit後の処理をキャンセルする
        event.preventDefault();
        // 要素をcanvas形式に変換する
        getImageDataUrl()
            .then((imageDataUrl) => {
                // [6] データURLを隠しフィールドにsetする
                hiddenImageDataUrl.setAttribute("value", imageDataUrl);
            })
            .then(() => {
                // [7] submit処理を実行する
                form.submit();
          });

    async function getImageDataUrl() {
    // [4] 要素をcanvas形式に変換する
        const canvas = await html2canvas(code_body);
    // [5] canvasデータをデータURLに変換する
        return canvas.toDataURL("image/jpeg", 1.0);
    }
  })
})

ビュー

app/views/codes/_form.html.erb

<div class="field">
    <%= form.hidden_field :image_data_url, value: "" %>
</div>

app/views/codes/show.html.erb

<p>
  <strong>Image:</strong>
</p>
<p>
  <%= image_tag(@code.image) %>
</p>

コントローラー

image_data_url をStrong Parameters で受け取り、画像をattachします。

dataURLをActiveStorageに保存する処理はこちらを参考にさせていただきました。

zenn.dev

処理の流れやデータURLなどについて書くと長くなってしまうのですが、以前フィヨルドブートキャンプの日報で雑に書いたことをScrapboxに雑に転記したので載せておきます。

scrapbox.io

app/controllers/code_controller.rb

# frozen_string_literal: true

class CodeController < ApplicationController
  before_action :set_code, only: %i[show destroy]

  def create
    @code = Code.new(code_params)
    @code.attach_blob(image_data_url)

    if @code.save
      redirect_to @code, notice: 'Code was successfully created.'
    else
      render :new, status: :unprocessable_entity
    end
  end

private

  def set_code
    @code = Code.find(params[:id])
  end

  def code_params
    params.require(:code).permit(:body)
  end

  def image_data_url
    params.require(:code).permit(:image_data_url)[:image_data_url]
  end
end

モデル

app/models/code.rb

# frozen_string_literal: true

class Code < ApplicationRecord
  has_one_attached :image

  def attach_blob(image_data_url)
    image_blob = ImageBlob.new(image_data_url)
    image.attach(
      io: image_blob.to_io,
      filename: Time.zone.now,
      content_type: image_blob.mime_type
    )
  end
end

app/models/image_blob.rb

class ImageBlob
  attr_reader :image_data_url

  def initialize(image_data_url)
    @image_data_url = image_data_url
  end

  def mime_type
    image_data_url[%r/(image\/[a-z]{3,4})/]
  end

  def to_io
    StringIO.new(decoded_content)
  end

  private

  def decoded_content
    Base64.decode64(content)
  end

  def content
    image_data_url.sub(%r/data:image\/.{3,},/, '')
  end
end

これで画像をサーバーに保存する処理ができました🎉

注意事項

turbolinksがオンになっているとJS側の処理がうまく動かないことがあります。その場合はturbolinksをオフにするか、addEventListenerで指定するイベントをDOMContentLoadedからturbolinks:loadに変えてみてください。

本番環境の画像の保存先をローカルにすると、herokuでデプロイした場合一定時間で画像データが消えてしまうので、S3などの画像サーバーに保管する必要があります。*1

画像毎にOGPの画像を設定するときに、相対パスurl_for指定だとTwitterカードの反映ができません。絶対パスpolymorphic_urlで指定する必要があるようです。*2

さいごに

今回はformのtextareaを画像化しましたが、自分が作成したアプリではtextareaに入力したコードをいい感じにした要素を画像化して使っていました。

f:id:yana_g:20211220183424p:plain

イデア次第では色々面白いことができそうなので、参考にしてもらえると嬉しいです!

参考

Vue.jsで画像を作成しRailsのActive Storageで保存する

base64でエンコードされた画像をActive Storageで保存する - Qiita