Canvasのライブラリ「Konva.js」でOGP生成(Nuxtアプリ)

読了目安:19分

Nuxt.jsとFirebaseで作っていたWebサービスを7月末にリリースして、はや10ヶ月。。
登録総額1億円を突破したので、記念企画として積読レビュー機能をリリースしました!!

そこでKonva.jsというライブラリを使ってOGP画像を生成したので、
その時に調べたことをまとめてみました。

積読・読書前でも書ける『積読レビュー』

読書のレビューというと、読書後の感想を書くものですが、
積ん読が多いとなかなか書く機会がないです。。

「感想は読書後だけではないのでは?」
ということで、読書前でも書けるレビューを2つ用意してみました。

妄想で書く『妄想レビュー』

まだ読んでないけど、本の表紙や帯の印象から妄想で書いてみる『妄想レビュー』

「中身じゃなく外観だけで、読書後のレビューを書いてみるとどうだろう。。?」
というネタ的なレビューです。

いかに読んでる醸し出せるかをチャレンジしてみるとおもしろいかも(´ω`)

他の使い方としては、読む前に書いておいて、読んだ後との感想を比較してみるのもたのしいかも?
読書前後で感想が同じになっても違っていても、新たな発見があるかもです。

きっかけを書く『きっかけレビュー』

買ったときのきっかけや意気込み・ワクワク感を書いてみる『きっかけレビュー』

「『なぜ買うのか、どうして買いたいのか』という気持ちを残しておくのもどうだろう。。?」
と思いつきつくってみました。

買ったときの気持ちも大事で、
* 技術書やビジネス書なら「こうなりたい!ここを強くしたい!」
* 小説やマンガなら「ひさびさの新刊!たのしみ!」
という思いがあるはず・・

それを記録として残しておくと、いつか見返したときにたのしいかもしれません。

なぜKonva.js?

この企画では、本を選んで、レビューを書いていくのですが、
本の表紙など画像を埋め込む必要があります。

以前、別のサービスで、SVGでOGP用の画像を生成してみたのですが、端末によってはうまくいかなかったりと、画像を埋め込むのがかなり大変でした。。

なので、SVGではなく、Canvasで試してみようと調べたところ、
Konva.jsFabric.jsが見つけ、
文字の折返しなどのガイドがあったKonva.jsを選んでみました。

Konvaで画像生成

ここからが本題。やっと本題。。

準備

インストール

$ npm install vue-konva konva --save

今回は、Nuxt/Vueで使うので、KonvaのVueライブラリ(vue-konva)もインストール

プラグインの作成

vue-konvaを使えるようプラグインを作成。

// ~/plugins/vue-konva.ts
import Vue from "vue";
import VueKonva from "vue-konva";

Vue.use(VueKonva);

そして、作ったプラグインをnuxt.config.tsに追加。

// nuxt.config.js
import { Configuration } from "@nuxt/types";

const config: Configuration = {
  mode: "spa",
  // 略

  plugins: [
    { src: "~/plugins/vue-konva", ssr: false },
    // 略
  ],

  // 略
};

これで準備はOK

KonvaでCanvasを描いてみる

画像を表示する(v-image)

まずは、フレームとなる以下の背景画像を表示してみます。

<template>
  <div>
    <v-stage :config="configKonva">
      <v-layer>
        <v-image :config="configBg"></v-image>
      </v-layer>
    </v-stage>
  </div>
</template>

<script lang="ts">
import { Component, Vue, Prop, Watch } from "nuxt-property-decorator";

const IMAGE_WIDTH = 1200;
const IMAGE_HEIGHT = 630;

@Component
export default class ReviewPreviewSample extends Vue {
  // konva全体の設定(v-stageに対応)
  private configKonva = { width: IMAGE_WIDTH, height: IMAGE_HEIGHT };
  // 背景画像の設定(v-imageに対応)
  private configBg: { image: HTMLImageElement | null } = { image: null };

  async mounted() {
    // マウントされたら、背景画像を読み込んで設定する
    this.setupBg().then();
  }

  // 背景画像の設定
  private async setupBg() {
    const image = await this.getImage("/img/review_paple.png");
    this.configBg.image = image;
  }

  // 共通処理: 画像の読み込み
  private async getImage(src: string): Promise<HTMLImageElement> {
    return new Promise((resolve, reject) => {
      const image = new window.Image();
      image.onload = () => resolve(image);
      image.src = src;
    });
  }
}
</script>

こんな感じ。

基本的な構成

基本的な構成は、こんな感じで、
v-stage > v-layer > その他諸々
で配置していく。

<v-stage :config="configKonva">
  <v-layer>
    <v-image :config="configBg"></v-image>
  </v-layer>
</v-stage>

必ずrootがv-stageで、その直下がv-layer
v-layerはいくつも作れるけど、3〜4までがよいっぽい。(waringがでた)

位置やサイズ、表示する画像は:configを介して、Kanva.js自体の設定値を付与していく。

どんな設定値があるかは、ApiDocを見ていく感じで、
Imageだとここ(Class: Image)をみる

チュートリアルもあるので、それを見つつ、
具体的な設定値はApiDoc参照という流れで進めた。

画像の表示には読み込みが必要

画像の表示は少しめんどくさくて、

  1. HTMLImageElementをつくって
  2. HTMLImageElementで画像をロードして
  3. ロード後のHTMLImageElementをconfigに設定

という流れ。。

それをしているのがこのあたり。
画像読み込みは何度も使うけど、Promiseじゃないので、
ロードが終わったらHTMLImageElementを返す処理を共通化

async mounted() {
  // マウントされたら、背景画像を読み込んで設定する
  this.setupBg().then();
}

// 背景画像の設定
private async setupBg() {
  const image = await this.getImage("/img/review_paple.png");
  this.configBg.image = image;
}

// 共通処理: 画像の読み込み
private async getImage(src: string): Promise<HTMLImageElement> {
  return new Promise((resolve, reject) => {
    const image = new window.Image();
    image.onload = () => resolve(image);
    image.src = src;
  });
}

これでできるのがこんな感じ。

スクリーンショット 2020-05-06 12.46.54.png

でかい。。
CanvasのサイズをOGP画像のサイズ(1200x630)にしているもんね。。

画像を画面サイズにあわせる

ほんとはこんな感じにしたい。。

ので、Windowサイズの変更を検知して、CSSのscaleで調整してみる。

こんな感じ。

<template>
  <!-- サイズ計算用にidが必要なので追加 -->
  <div id="preview-wrapper" class="preview-wrapper">
    <v-stage :config="configKonva" class="preview-content" :style="style">
      <v-layer>
        <v-image :config="configBg"></v-image>
      </v-layer>
    </v-stage>
  </div>
</template>

<script lang="ts">
import { Component, Vue, Prop, Watch } from "nuxt-property-decorator";

const IMAGE_WIDTH = 1200;
const IMAGE_HEIGHT = 630;

@Component
export default class ReviewPreviewSample extends Vue {
  private configKonva = { width: IMAGE_WIDTH, height: IMAGE_HEIGHT };
  private configBg: { image: HTMLImageElement | null } = { image: null };

  private scale: number = 0;

  async mounted() {
    this.setupBg().then();

    // 初回のスケール計算処理を走らせる
    this.$nextTick(() => this.handleResize());
    // イベントリスナーにスケール計算処理を登録
    window.addEventListener("resize", this.handleResize);
  }

  beforeDestroy() {
    // Destroy前に解放
    window.removeEventListener("resize", this.handleResize);
  }

  private get style() {
    // transformのscaleを使って、サイズ調整
    return {
      transform: `scale(${this.scale})`,
      "-webkit-transform": `scale(${this.scale})`,
      "transform-origin": "0 0"
    };
  }

  // スケール計算用の処理: 対象IDのサイズを取得して、scaleを計算
  private handleResize() {
    const elm = document.getElementById("preview-wrapper");
    if (!elm) return;
    const rect = elm.getBoundingClientRect();

    this.scale = rect.width / IMAGE_WIDTH;
  }

  private async setupBg() {
    // 略
  }

  private async getImage(src: string): Promise<HTMLImageElement> {
    // 略
  }
}
</script>

<style lang="scss" scoped>
/* 画像のアスペクト比は固定なので、あらかじめ高さをCSSで調整 */
.preview-wrapper {
  position: relative;
  width: 100%;
  height: auto;
}

.preview-wrapper {
  &:before {
    content: "";
    display: block;
    padding-top: 52.5%; /* 630 / 1200 x 100 */
  }
}

.preview-content {
  position: absolute;
  top: 0;
  left: 0;
}
</style>

これでOK。Konva.jsの話に戻る

画像を加工する(サイズ変更)

次は書影を読み込んで、配置する部分。
書影は本ごとにサイズが違うので、加工が必要。。

スクリーンショット_2020-05-06_13_06_55.png

<template>
  <div id="preview-wrapper" class="preview-wrapper">
    <v-stage :config="configKonva" class="preview-content" :style="style">
      <v-layer>
        <v-image :config="configBg"></v-image>
      </v-layer>

      <v-layer>
        <!-- 書影の画像 -->
        <v-image :config="configBookImg"></v-image>
      </v-layer>
    </v-stage>
  </div>
</template>

<script lang="ts">
import { Component, Vue, Prop, Watch } from "nuxt-property-decorator";

const IMAGE_WIDTH = 1200;
const IMAGE_HEIGHT = 630;
const BOOK_IMAGE_WIDTH = 464;
const BOOK_IMAGE_HEIGHT = 470;

@Component
export default class ReviewPreviewSample extends Vue {
  // 略
  private configBookImg: any | null = null;

  async mounted() {
    this.setupBg().then();
    // 略
  }

  // 略

  private async setupBookImage() {
    // 画像の読み込み
    const imageBook = await this.getImage("/img/notfound_paple.png");

    // v-imageのconfig
    const scale = BOOK_IMAGE_WIDTH / imageBook.width;
    this.configBookImg = {
      image: imageBook,
      x: 48,  // 左上からの位置(x)
      y: 112, // 左上からの位置(y)
      width: BOOK_IMAGE_WIDTH,   // 画像のサイズ(横)
      height: BOOK_IMAGE_HEIGHT, // 画像のサイズ(縦)
      crop: {  // 切り取り処理
        x: 0,  // 切り取り位置(x)
        y: 0, // 切り取り位置(y)
        width: imageBook.width,           // 切り取りサイズ(横)
        height: BOOK_IMAGE_HEIGHT / scale // 切り取りサイズ(縦)
      }
    };
  }

  // 略
}
</script>

<style lang="scss" scoped>
/* 略 */
</style>

こんな感じ。切り取りは、v-imageのcropを指定すればOK。

ここで若干ハマった。。

【ハマり1】cropとscaleを同時に指定するとうまくいかない。。

サイズを調整しようと、いろいろしていたときに、
スケールを変化させるscaleと切り取り処理のcropを同時に指定してみたけど、
あまりうまくいかず、cropのみで対応した。。

【ハマり2】computedだとうまくいかない。。

最初は、以下のような感じで、computedにしてたけど、
cropなど、配下のobjectが変わっても変更が反映されなかったので、
dataのconfigBookImgを用意する形にしてみた。

get configBookImg() {
  // 画像の読み込み
  const imageBook = await this.getImage("/img/notfound_paple.png");

  // v-imageのconfig
  const scale = BOOK_IMAGE_WIDTH / imageBook.width;
  return {
    image: imageBook,
    x: 48,  // 左上からの位置(x)
    y: 112, // 左上からの位置(y)
    width: BOOK_IMAGE_WIDTH,   // 画像のサイズ(横)
    height: BOOK_IMAGE_HEIGHT, // 画像のサイズ(縦)
    crop: {  // 切り取り処理
      x: 0,  // 切り取り位置(x)
      y: 0, // 切り取り位置(y)
      width: imageBook.width,           // 切り取りサイズ(横)
      height: BOOK_IMAGE_HEIGHT / scale // 切り取りサイズ(縦)
    }
  };
}

画像を加工する(丸く切り取る)

次はこれ。ユーザのアイコン画像。

スクリーンショット_2020-05-06_14_08_51.png

もともとは、こんな四角い画像だけど、アイコンっぽく丸く切り取りたい。

チュートリアルを見てみると、v-groupを使うとできるっぽい。

<template>
  <div id="preview-wrapper" class="preview-wrapper">
    <v-stage :config="configKonva" class="preview-content" :style="style">
      <!-- 略 -->

      <v-layer>
        <v-group :config="configUserImageGroup">
          <v-image :config="configUserImg"></v-image>
        </v-group>
      </v-layer>
    </v-stage>
  </div>
</template>

<script lang="ts">
import { Component, Vue, Prop, Watch } from "nuxt-property-decorator";

const IMAGE_WIDTH = 1200;
const IMAGE_HEIGHT = 630;
const BOOK_IMAGE_WIDTH = 464;
const BOOK_IMAGE_HEIGHT = 470;
const USER_IMAGE_SIZE = 76;

@Component
export default class ReviewPreviewSample extends Vue {
    // 略
  private configUserImg: any | null = null;


  async mounted() {
    // 略
    this.setupUserImage().then();
    // 略
  }

  // 略

  private get configUserImageGroup() {
    return {
      // 複雑な切り取り処理: CanvasRenderingContext2Dのarcを使う
      clipFunc: ctx => {
        const r = USER_IMAGE_SIZE / 2;
        ctx.arc(1092 + r, 538 + r, r, 0, Math.PI * 2, false);
      },
      // ドラッグを無効にする
      draggable: false
    };
  }

  // ユーザ画像の読み込み処理: 本の画像とかとだいたい同じ
  private async setupUserImage() {
    const image = await this.getImage("/img/kira_puka.png");

    this.configUserImg = {
      image: image,
      x: 1092,
      y: 538,
      width: USER_IMAGE_SIZE,
      height: USER_IMAGE_SIZE
    };
  }

  // 略
}
</script>

<style lang="scss" scoped>
/* 略 */
</style>

v-groupclipFuncを使うと、Canvaを操作できるようで、
CanvasRenderingContext2Dのarcを使って、丸く切り取り。

注意点は、原点がCanvas(v-stage)の左上であること。
画像の左上が原点だと思っていたので、全然切り取られなかった。。

1行の文字を表示する(省略あり)

画像はここまで。次は文字。本のタイトルを表示したい。

スクリーンショット_2020-05-06_14_20_00.png

<template>
  <div id="preview-wrapper" class="preview-wrapper">
    <v-stage :config="configKonva" class="preview-content" :style="style">
      <!-- 略 -->

      <v-layer>
        <v-text :config="configBookTitle"></v-text>
      </v-layer>
    </v-stage>
  </div>
</template>

<script lang="ts">
import { Component, Vue, Prop, Watch } from "nuxt-property-decorator";

// 略

@Component
export default class ReviewPreviewSample extends Vue {
  // 略

  private get configBookTitle() {
    return {
      // 表示する文字
      text: "選んだ本のタイトル 選んだ本のタイトル 選んだ本のタイトル 選んだ本のタイトル",
      x: 200,
      y: 28,
      width: IMAGE_WIDTH - 400,
      fill: "#FFFFFF",            // 文字の色
      fontSize: 36,               // フォントサイズ
      fontFamily: "Noto Sans JP", // フォントの種類
      fontStyle: "bold",          // フォントのスタイル
      align: "center",            // 揃え位置(中央揃え)
      // 折り返しなし(wrap)を指定すると、省略(ellipsis)を設定可能に
      wrap: "none",
      ellipsis: true
    };
  }

  // 略
}
</script>

<style lang="scss" scoped>
/* 略 */
</style>

文字を表示するには、v-textを使えばOK

CSSのtext-overflowと同じ感じで、折返しをnoneにすれば、省略記号の表示もできる。

複数行の文字を表示する(省略不可)

次はレビューの文字を表示。

スクリーンショット_2020-05-06_14_30_22.png

<template>
  <div id="preview-wrapper" class="preview-wrapper">
    <v-stage :config="configKonva" class="preview-content" :style="style">
      <!-- 略 -->

      <v-layer>
        <v-text :config="configReview"></v-text>
      </v-layer>
    </v-stage>
  </div>
</template>

<script lang="ts">
import { Component, Vue, Prop, Watch } from "nuxt-property-decorator";

// 略

@Component
export default class ReviewPreviewSample extends Vue {
  // 略

  private get configReview() {
    return {
      text: "レビューの内容れびゅーのないよう\n\nレビューの内容れびゅーのないようレビューの内容れびゅーのないようレビューの内容れびゅーのないようレビューの内容れびゅーのないようレビューの内容れびゅーのないようレビューの内容れびゅーのないようレビューの内容れびゅーのないようレビューの内容れびゅーのないよう",
      x: 596,
      y: 188,
      width: 542,
      height: 270,
      fill: "#443a33",
      fontSize: 30,
      fontFamily: "Noto Sans JP"
      // 行の高さ
      lineHeight: 1.2,
    };
  }

  // 略
}
</script>

<style lang="scss" scoped>
/* 略 */
</style>

同じくv-textを使う感じ。
wrapを指定しなければ、そのまま複数行に。

改行文字を入れれば改行もしてくれて、widthを指定すれば折返しも自動。

ただ、残念ながら、-webkit-line-clampみたいに複数行の省略はないみたい。。

Canvasを画像にエクスポート

最後に作った画像をエクスポート!!

スクリーンショット_2020-05-06_14_41_16.png

<template>
  <div>
    <div id="preview-wrapper" class="preview-wrapper">
      <!-- v-stageが取得できるようにrefを追加-->
      <v-stage
        ref="stage"
        :config="configKonva"
        class="preview-content"
        :style="style"
      >
        <!-- 略 -->
      </v-stage>
    </div>
    <!-- ボタンを追加 -->
    <div class="has-text-centered">
      <b-button type="is-primary" size="is-medium" @click="download">
        <span>作成する</span>
      </b-button>
    </div>
  </div>
</template>

<script lang="ts">
import { Component, Vue, Prop, Watch } from "nuxt-property-decorator";

// 略

@Component
export default class ReviewPreviewSample extends Vue {
  // 略

  // ダウンロード処理
  private async download() {
    // stageの取得: 大きいサイズを取得するときは、'pixelRatio'で倍率を指定
    const stage = (this.$refs.stage as any).getStage({ pixelRatio: 1 });

    // stageからDataURLを取得
    const dataURL = stage.toDataURL();

    // ダウンロード処理: プログラムでリンクを作ってクリック
    const link = document.createElement("a");
    link.download = "ogp.png";
    link.href = dataURL;
    document.body.appendChild(link);
    link.click();
    document.body.removeChild(link);
  }
}
</script>

<style lang="scss" scoped>
/** 略 **/
</style>

こんな感じ。Konvaでエクスポートするときには、v-stageから取得する。
DataURL以外にJSONとかでもエクスポートできるので、保存したりもOK

開発しているサービスでは、FirebaseのCloud Storageに保存しているので、DataURL形式でOK。

ハマリポイントは、外部URLの画像を使っているとき。。エクスポートに失敗する。。
Cavans自体の仕様で外部URLの画像がある場合、汚染されたとみなされエラーになるとのこと。。

対処法は以下に書かれている感じで、CORS対応をすればOK
Resolving "Tainted canvases may not be exported" with Konva | Konva - JavaScript 2d canvas library

おわり

SVGでも外部URLは問題があったけど、Konva.jsでも。。
でも、サクサクできたのと、ドラッグで位置を変えれるみたいなので、いろいろできそう(´ω`)

積ん読レビューはできたての機能なので、ぜひぜひさわってみてくださいヽ(=´▽`=)ノ
どっちでレビューする?妄想レビュ・きっかけレビュー

Originally published at qiita.com
ツイッターでシェア
みんなに共有、忘れないようにメモ

きらぷか@i18n補助ツール『トランスノート』開発者

フリーエンジニア/今はNuxt.js/いつかFlutter 受託&アプリ/Webサービス/ゲームを #個人開発 CS修士→SIer/R&D→フリー #paiza はAランクで満足/AtCoderしたい 仕事依頼やご相談はDMまで Kotlin/Python/Swift/Unity/Java/Haskell/DDD

Crieitは個人で開発中です。 興味がある方は是非記事の投稿をお願いします! どんな軽い内容でも嬉しいです。
なぜCrieitを作ろうと思ったか

また、「こんな記事が読みたいけど見つからない!」という方は是非記事投稿リクエストボードへ!

有料記事を販売できるようになりました!

こじんまりと作業ログやメモ、進捗を書き残しておきたい方はボード機能をご利用ください!
ボードとは?

関連記事

コメント