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

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

そこで[Konva.js](https://konvajs.org/)というライブラリを使ってOGP画像を生成したので、
その時に調べたことをまとめてみました。

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

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478782/fc0f21b7-ee49-f01c-34a1-0d44ed3022d2.png" width="600px"/>


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

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

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

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478782/8e8a9968-c44f-64c8-93d2-0f8eb1f3172a.png" width="600px"/>


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

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

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

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

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

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478782/e740910f-2687-0484-e3d2-bebdd0d95bf6.png" width="600px"/>


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

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

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

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

## なぜKonva.js？

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

以前、[別のサービス](http://hen-ai.net/)で、[SVGでOGP用の画像を生成](https://www.memory-lovers.blog/entry/2020/01/24/110000)してみたのですが、端末によってはうまくいかなかったりと、画像を埋め込むのがかなり大変でした。。

なので、SVGではなく、Canvasで試してみようと調べたところ、
[Konva.js](https://konvajs.org/)と[Fabric.js](http://fabricjs.com/)が見つけ、
文字の折返しなどのガイドがあった[Konva.js](https://konvajs.org/)を選んでみました。

## Konvaで画像生成

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

### 準備

#### インストール

```shell
$ npm install vue-konva konva --save
```

今回は、Nuxt/Vueで使うので、KonvaのVueライブラリ([vue-konva](https://konvajs.org/docs/vue/index.html))もインストール


#### プラグインの作成

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

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

Vue.use(VueKonva);
```

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

```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)

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

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478782/9033133e-55f5-17eb-a8bc-85ce000a68ef.png" width="600px"/>


```html
<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 > その他諸々`
で配置していく。

```html
<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](https://konvajs.org/api/Konva.html)を見ていく感じで、
Imageだと[ここ(Class: Image)をみる](https://konvajs.org/api/Konva.Image.html)

[チュートリアル](https://konvajs.org/docs/)もあるので、それを見つつ、
具体的な設定値は[ApiDoc](https://konvajs.org/api/Konva.html)参照という流れで進めた。

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

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

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

という流れ。。

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

```ts
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;
  });
}
```

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

<img width="600px" alt="スクリーンショット 2020-05-06 12.46.54.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478782/b295f2ff-7b56-5ab4-1e9c-055ef7ff4314.png">

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


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

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

<img width="600px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478782/e3210e3f-c4b0-6e6c-1a11-e791dc90dd05.gif" />


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

こんな感じ。

```html
<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の話に戻る


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

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

<img width="600" alt="スクリーンショット_2020-05-06_13_06_55.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478782/733c10bc-58e3-ffd4-45cd-09d344c9eb4f.png">

```html
<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を用意する形にしてみた。

```ts
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 // 切り取りサイズ(縦)
    }
  };
}
```

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

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

<img width="600" alt="スクリーンショット_2020-05-06_14_08_51.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478782/80f51e08-b4f4-5fd5-fa3e-35ef560b7197.png">


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

<img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478782/62f0de4a-a307-82be-9755-a1a0963b9e2e.png" />


[チュートリアル](https://konvajs.org/docs/clipping/Clipping_Function.html)を見てみると、`v-group`を使うとできるっぽい。


```html
<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-group`の`clipFunc`を使うと、Canvaを操作できるようで、
[CanvasRenderingContext2Dのarc](https://developer.mozilla.org/ja/docs/Web/API/CanvasRenderingContext2D/arc)を使って、丸く切り取り。

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


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

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

<img width="600" alt="スクリーンショット_2020-05-06_14_20_00.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478782/5042be5a-3901-780e-34eb-3616558ed1e8.png">

```html
<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にすれば、省略記号の表示もできる。

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

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

<img width="600" alt="スクリーンショット_2020-05-06_14_30_22.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478782/75fc126e-1179-e8d1-a2ab-6aa1aaf44974.png">


```html
<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を画像にエクスポート

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

<img width="500" alt="スクリーンショット_2020-05-06_14_41_16.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478782/57be76de-b8c2-3a34-31f8-5b85079b621c.png">


```html
<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

- [HTML5 Canvas Stage Data URL Tutorial | Konva - JavaScript 2d canvas library](https://konvajs.org/docs/data_and_serialization/Stage_Data_URL.html)

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

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

対処法は以下に書かれている感じで、CORS対応をすればOK
・[Resolving "Tainted canvases may not be exported" with Konva | Konva - JavaScript 2d canvas library](https://konvajs.org/docs/posts/Tainted_Canvas.html)


## おわり

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

積ん読レビューはできたての機能なので、ぜひぜひさわってみてくださいヽ(=´▽`=)ﾉ
■[どっちでレビューする？妄想レビュ・きっかけレビュー](https://tsundoku.site/review/)

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478782/fc0f21b7-ee49-f01c-34a1-0d44ed3022d2.png" width="600px"/>