くらげになりたい。

くらげのようにふわふわ生きたい日曜プログラマなブログ。趣味の備忘録です。

SVGでOGP用のPNG画像を生成してみる(折り返し文字、画像埋め込み、PNG化)

SVGでOGP画像を作りたいなと思い、いろいろ調べたときの備忘録。

SVGはまるのも多いけど、いろいろできるので、
SVGからOGP画像をつくるのいいかもしれない。

とりあえず、SVGを書いてみる

ちょろっとなるなら、HTMLでベタ書きしてみるのもいい。
動作確認とか楽ちん。

<html>
<body>
  <svg viewbox="0 0 1200 630" width="1200" height="630" style="background-color: lightgray;">
    <rect x="550" y="265" width="100" height="100"  fill="blue" />
    <circle cx="550" cy="265" r="30" fill="none" stroke="red" stroke-width="5" />
  </svg>
</body>
</html>

こんな感じ。

f:id:wannabe-jellyfish:20200124001137p:plain

<rect>で四角を書いて、<circle>で丸を書いてる。

上から順に描画されるので、下にある方が前面にくる感じ。
なので、四角の上に丸が描画されてる。

<rect>の座標(x,y)は左上の場所なので、
横幅と縦幅分の半分だけ、全体の中心からずらしてる。

画像化する

最終的にOGPで使うPNG画像にしたい。

画像化する方法について、ローカルとブラウザ上の2つを試した。

1. ローカルで画像化する

いろんな画像フォーマットに対応しているsharpがよさそう。
sharp - High performance Node.js image processing

.svgファイルを.pngに簡単にできる。

sharpsvgpngに変換してみる

まずは、.svgとして扱えるように、
xmlnsとかをちゃんとつけたsample.svgを用意する。

<!-- sample.svg -->
<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg" viewbox="0 0 1200 630" width="1200" height="630" fill="lightgray">
  <rect x="550" y="265" width="100" height="100" fill="blue" />
  <circle cx="550" cy="265" r="30" fill="none" stroke="red" stroke-width="5" />
</svg>

パッケージをインストール

$ npm i sharp

変換するコードはこんな感じ。

// generate.js
const sharp = require("sharp");

async function main() {
  await sharp("sample.svg")
    .png()
    .toFile("output.png");
}

main().then();

そして、実行。

$ node generate.js

すると、output.pngというPNGファイルを作成してくれる。

ただ、svgのstyleは反映してくれないので注意

// generate.js
const sharp = require("sharp");

async function main() {
  await sharp("sample.svg")
    .png()
    .toFile("output.png");
}

main().then();

背景色をつける場合は、sharp側で設定すればOK

// generate.js
const sharp = require("sharp");

async function main() {
  await sharp("sample.svg")
    .flatten({ background: "lightgray" }) // 背景色
    .png()
    .toFile("output.png");
}

main().then();

2. ブラウザ上のSVGを画像化する

ユーザに情報を入力してもらった内容をSVGで表示して、
OGP画像にするという感じが多いので、こっちがメイン。

保存先のFirebase StorageがData URL形式に対応しているので、
PNGのData URLが取得できればOK。

大まかな流れは、こんな感じ。

  1. Canvasを用意する
  2. SVGを読み込む<image>を用意する
  3. <svg>を文字列に変換
  4. 作成した<image>にDataURL形式でSVGをセットして、読み込み開始
  5. 読み込みが完了したら、Canvasに書き出して
  6. CanvasからDataURLを取得する
// svg2DataURL.ts

/**
 * svgをpngに変換
 * @param svgElement <svg>のHTML要素
 */
export default function svg2DataURL(
  svgElement: HTMLElement
): Promise<HTMLCanvasElement> {
  return new Promise((resolve, reject) => {

    // 1. Canvasを用意する
    const canvas = document.createElement("canvas");
    canvas.width = 1200;
    canvas.height = 630;
    const ctx = canvas.getContext("2d");
    if (!ctx) {
      reject(Error("Create Canvas Error..."));
      return;
    }

    // 2. SVGを読み込む<image>を用意する
    const image = new Image();
    image.decoding = "async";
    image.onload = () => {
      // 5. 読み込みが完了したら、Canvasに書き出して、
      ctx.drawImage(image, 0, 0, 1200, 630);
      // 6. CanvasからDataURLを取得する
      resolve(canvas.toDataURL());
    };
    image.onerror = e => reject(e);

    // 3. <svg>を文字列に変換
    const svgXml = new XMLSerializer().serializeToString(svgElement);
    const svgData = btoa(unescape(encodeURIComponent(svgXml)));
    
    // 4. 作成した<image>にDataURL形式でセットして、読み込み開始
    image.src = `data:image/svg+xml;charset=utf-8;base64,${svgData}`;
  });
}

あとは、好きなタイミングで呼び出せばOK。

Nuxt.jsでの例はこんな感じ。

<template>
  <div>
    <!-- document.getElementByIdできるように、idをつけておく -->
    <svg id="svg" viewbox="0 0 1200 630" width="1200" height="630" style="background-color: lightgray;">
      <rect x="550" y="265" width="100" height="100"  fill="blue" />
      <circle cx="550" cy="265" r="30" fill="none" stroke="red" stroke-width="5" />
    </svg>
  </div>
  
  <div>
    <a class="button" @click="saveSVG">画像を保存</a>
  </div>
</template>

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

// 初期化済みのfirebaseインスタンス。詳細は略
import firebase from "~/plugins/firebase";

import svg2DataURL from "./svg2DataURL";

@Component()
export default class SaveSvgPage extends Vue {

  // 画像を保存する処理
  async saveSVG() {
    // svgのHTML要素を取得
    const elm = document.getElementById("svg");
    if (!elm) return;
    
    // さっきの処理: HTML要素からDataURLを取得
    const dataURL = await svg2DataURL(elm);
    
    // Cloud Storage for Firebaseへ保存
    const filePath = "...保存する先のパス..."
    const storage = firebase.storage();
    const fileRef = storage.ref().child(filePath);
    await fileRef.putString(dataURL, "data_url");
  }
</script>

これでSVGがCloud Storage for FirebaseにPNG画像で保存できる(´ω`)

ハマったポイント

1. 画像化するときにhtml2canvasを使うといいかんじにならない

画像化する方法でhtml2canvasもあるけど、うまくいかなかった。。

スクロール位置や画面サイズによって、
うまく撮れるときと撮れない時があって、この方法に。。

2. ブラウザ上と保存した画像が違う

この後出てくる小ネタ集でスタイルで装飾する方法を使ったところ、
うまくいかない感じに。。

svg内にsytleをもたせたらうまくいった(´ω`)

ブラウザ上 = 全体のCSSが適用 保存画像 = SVG配下のCSSのみ適用

なので、SVGだけで完結しないといけない...

フォントとかも指定しないと、見た目が変わってしまう。。

3. 背景画像など外部リンクがあるとうまく保存できない...

Canvasの仕様っぽく、外部リンクでCORSで引っかかると画像が表示されないよう...

SVGで完結するように、読み込んだ画像をDataURL形式で指定するようにしたらうまくいった(´ω`)

SVGで完結」が大事らしい。。

4. Nuxt.jsでbuildすると<style>がきえる...

minifyするときになぜか<style>が消えてしまう現象が...

<style>だけのコンポーネントを作って、いれると大丈夫な感じ(´ω`)

小ネタ集

svg内でstyleが使える

<style>タグがあるらしい。
classを設定して、いろいろできる。便利。

<html>
<body>
  <svg viewbox="0 0 1200 630" width="1200" height="630" style="background-color: lightgray;" >
    <style>
      .rect {
        fill: green;
      }
    </style>
    <rect class="rect" x="600" y="315" width="100" height="100"/>
  </svg>
</body>
</html>

折り返し文字を表示してみる(foreignObject)

SVGにも<text>があるけど、自動折り返しに対応していない。
文字数を計算して自分で分割すればできるけど、めんどくさい。。

文字の折り返ししたい場合、<foreignObject>という
HTMLを追加できるのがあるので、それを使うといいらしい。

styleでfont-sizeとかも設定できるのでいろいろできそう(´ω`)

<html>
<body>
  <svg viewbox="0 0 1200 630" width="1200" height="630" style="background-color: lightgray;" >
    <style>
      @import url("https://fonts.googleapis.com/css?family=Noto+Sans+JP:500&display=swap&subset=japanese");
      .item {
        font-family: "Noto Sans JP", sans-serif;
        font-size: 60px;
        border: 2px solid green;
      }
    </style>

    <foreignObject
      requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"
      width="200"
      height="200"
      x="500"
      y="215"
    >
      <div class="item">
        こんにちは
      </div>
    </foreignObject>
  </svg>
</body>
</html>

こんな感じ

f:id:wannabe-jellyfish:20200124001256p:plain

ただ、foreignObjectはサイズを自動計算してくれるわけではないので、
widthとheightを設定しないといけない。

画像を表示する(image)

SVGで画像を使うときは、<image>を使う。
<img>とは違う。

<html>
<body>
  <svg viewbox="0 0 1200 630" width="1200" height="630" style="background-color: lightgray;" >
    <image
      xlink:href="https://mdn.mozillademos.org/files/6457/mdn_logo_only_color.png"
      width="200"
      height="200"
      x="500"
      y="215"
    />
  </svg>
</body>
</html>

こんな感じ。

f:id:wannabe-jellyfish:20200124001313p:plain

ハマったポイントにも書いたとおり、このままCanvasに書き出すと、
保存時に画像が表示されないので、動的にDataURLを取得してセットするといい感じ。

<html>
<body>
  <svg viewbox="0 0 1200 630" width="1200" height="630" style="background-color: lightgray;" >
    <image
      xlink:href="data:image/svg+xml;charset=utf-8;base64,...略"
      width="200"
      height="200"
      x="500"
      y="215"
    />
  </svg>
</body>
</html>

URLからDataURLに変換するのは、こんな感じ。

// svg2DataURL.ts

/**
 * URLをDataURLに変換
 * @param 変換したい画像のURL
 */
export default function url2DataURL(
  url: string
): Promise<HTMLCanvasElement> {
  return new Promise((resolve, reject) => {

    // 1. Canvasを用意する
    const canvas = document.createElement("canvas");
    canvas.width = 1200;
    canvas.height = 630;
    const ctx = canvas.getContext("2d");
    if (!ctx) {
      reject(Error("Create Canvas Error..."));
      return;
    }

    // 2. SVGを読み込む<image>を用意する
    const image = new Image();
    image.decoding = "async";
    image.onload = () => {
      // 4. 読み込みが完了したら、Canvasに書き出して、
      ctx.drawImage(image, 0, 0, 1200, 630);
      // 5. CanvasからDataURLを取得する
      resolve(canvas.toDataURL());
    };
    image.onerror = e => reject(e);

    // 3. 作成した<image>にURLでセットして、読み込み開始
    image.src = url;
  });
}

画面サイズに合うように表示する(resize対応)

OGP画像としていい感じのサイズで保存したいけど、
表示するときは画面サイズにあった感じにしたい。。

svgにstyleが使えるので、リサイズ時にスケールを計算して、
transform: scale();で縮小する感じにしてみた。

<template>
  <div id="svg-wrapper" class="svg-wrapper">
    <svg class="svg-content" :viewbox="`0 0 ${svgWidth} ${svgHeight}`"
         :width="svgWidth" :height="svgHeight" :style="style">
      <rect x="550" y="265" width="100" height="100"  fill="blue" />
      <circle cx="550" cy="265" r="30" fill="none" stroke="red" stroke-width="5" />
    </svg>
  </div>
</template>

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

@Component()
export default class SvgPage extends Vue {
  private svgWidth: number = 1200;
  private svgHeight: number = 630;
  private scale: number = 1;

  mounted() {
    // マウント時にリサイズする
    this.$nextTick(() => this.handleResize());
    
    // windowのresizeイベントのリスナーに登録して、
    // 画面サイズが変わったら、スケールを再計算するようにする
    window.addEventListener("resize", this.handleResize);
  }
  
  beforeDestroy() {
    // 破棄されるときに、リスナーの登録を解除する
    window.removeEventListener("resize", this.handleResize);
  }

  // リサイズ用のスケールを計算する処理
  private handleResize() {
    const elm = document.getElementById("svg-wrapper");
    if (!elm) return;
    this.rect = elm.getBoundingClientRect();

    this.scale = this.rect.width / this.svgWidth;
  }
  
  // ****************************************************
  // * computed
  // ****************************************************
  private get style() {
    // 計算したスケールで縮小するようにtransformを設定する
    return { transform: `scale(${this.scale})` };
  }
}
</script>

<style>
svg {
  transform-origin: 0 0;
}

.svg-wrapper {
  position: relative;
  width: 100%;
  height: auto;
}

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

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

ただ、このまま保存すると縮小されたままになるので、 deepコピーでクローンして、transformをクリアしてから書き出すようにする。

// svg2DataURL.ts

/**
 * svgをpngに変換
 * @param svgElement <svg>のHTML要素
 */
export default function svg2DataURL(
  svgElement: HTMLElement
): Promise<HTMLCanvasElement> {
  return new Promise((resolve, reject) => {
    // deepコピーでクローン
    const elm = svgElement.cloneNode(true) as HTMLElement;
    // transformをクリア
    elm.style.transform = "";
    
    // ... 略
    
    // 3. <svg>を文字列に変換
    // ※ transformを削除したelmでsvgの文字列を取得
    const svgXml = new XMLSerializer().serializeToString(elm);
    
    // ... 略
  });
}

以上!!

参考にしたサイト様