category icon
2022-04-01
Node.js

Node.jsで動的なOGP画像の生成方法。軽量で環境に影響されない方法

Node.js
16.13.0
opentype.js
1.3.4
sharp
0.30.3
profile
hikaru
Software Developer / DIY'er

Node.js で opentype.js を使用した動的な OGP 画像の生成方法です。node-canvasvercel/og-image なども試しましたが、opentype.js を SVG に組み込んで実装するやり方がシンプルで環境依存がなく軽快な動作でした。

動的に OGP 画像が生成できると、下の画像のように Twitter などに記事のリンクを貼ると、記事専用の画像が表示されるようになります。

(1) 想定読者と環境

OGP とは何かを知っていて、Node.js での 動的 OGP の実装例を探している方向けの記事となります。

(2) 背景と課題

Web サービスやブログシステムを作っていると、動的な OGP の生成が必要になることがままあります。この OGP の動的生成は基本的にサーバー上で画像を動的に生成する必要があり、ブラウザ上での画像生成と異なり結構な率で罠にハマります。

しかも、この OGP の動的生成は『これだ!』と言えるような決定打が未だ無く、皆さん苦労されているのではないでしょうか…
私もかなり苦労したので、私なりの『当面はとりあえずこれだ!』を、この記事で共有できればと思います。

(3) 技術的な検討

私は Nuxt.js/Next.js を多用するので、サーバー側も Node.js を選定しています。この記事も Node.js での動的 OGP 画像の生成方法となります。

動的な OGP 生成はいくつか方法があり、大きく分けると以下の1種類(ブラウザ)+3種類(サーバー)かと思います。

  1. ブラウザで OGP 用の画像を生成して、サーバーに保存する

    以前、ブラウザの canvas + konva で OGP 画像を生成、サーバに保存して利用する Web サービスを作ったことがあります、シンプルな構成で罠も少ない感じでした。

    ただし、ブログなど静的にサイトをジェネレートする場合はブラウザが必要なこの構成には使えないのと、絵文字はブラウザの環境によって結果が異なる画像が生成される場合があります。

  2. サーバー上で HTML Canvas を使って画像を生成する

    node-canvas

    ネット上の記事を調べると、このパターンの実装例がとても多いです。同時に環境依存のライブラリを使うのでそのあたりで罠にハマった方も多そうです。私も本番環境は問題なかったのですが、Windows + Nuxt3(Server-API) 環境の開発モード実行時にホットリロードが[worker] Module did not self-register エラーで落ちる現象に悩まされ技術的に解決できませんでした。(多分 Nuxt3 のホットリロードがワーカースレッドで頑張ろうとした結果、node-canvas がオレもうむりぽって感じで落ちていると思ってます)

    node-pureimage

    node-canvas と異なり、100% JavaScript で実装された、Node.js 上で動作する HTML Canvas 機能です。太字や斜体などは未対応のようですが、自前で用意したフォントファイルが使えるようなので、要件は満たせそうです。(未検証ですが処理コストが高そうで私はまだ試していないです)

  3. サーバー上でヘッドレスブラウザを動作させてレンダリング結果を画像として保存する

    vercel/og-image, puppeteer

    動的 OGP 界隈の最近のトレンドは vercel/og-image かもしれません。ヘッドレスブラウザ上で描画した HTML をスクリーンショットして画像出力する方法です。なんとも豪快でアメリカンな感じです。HTML で描画するのでかなり自由度がありそうです。

    デメリットは環境に依存していることと、パッケージの容量は(たしか)数百MBあり、ヘッドレスブラウザなので処理コスト高めな所でしょうか。

  4. 💮 サーバー上で SVG を生成して、PNG/JPEG に変換したものを OGP とする

    ⇨ SVG + opentype.js + sharp

    今回採用した方法です。 SVG は <text>要素でテキストを埋め込めますが、Node.js で動作させる場合フォントを指定する style 属性が使えないため、日本語表示に難があります。そこで、opentype.js を使いフォントファイルからテキストを <path> 要素で生成して SVG として完成させた後に、OGP 用に PNG に変換します。

    この記事では、この方法を解説します。

(4) 具体的な解決方法

opentype.js

opentype.js は Node.js とブラウザで動作します。
WOFF, OTF, TTF 形式のフォントを読み込んで、指定したテキストのベジェ曲線パス(SVGのパス)を生成できます。

以下のような SVG の path を opentype.js で生成して、SVG 画像を作ります。

svg
<svg width="1200" height="630">
  <!-- テキストのパス -->
  <path>〝ここを opentype.js で生成する〟</path>
</svg>

sharp

sharp は画像の変換や合成などの画像処理を Node.js で実行できます。

内部的には C 言語で実装された libvips を利用しており、ImageMagick/GraphicsMagick よりも高速に動作するそうです。(未検証)

今回は opentype.js で生成した SVG 画像を PNG に変換するために使用します。

実装例

Node.js ではスタンダードな express を使って OGP 画像を返す実装例を以下に示します。ライセンスは MIT としますので、ご自由にお使いください。

GitHub / Node.js OGP Sample

index.ts (共通部分)
import * as express from 'express';
import * as opentype from 'opentype.js';
import * as sharp from 'sharp';

const app = express();
const port = 3000;

// opentype: フォントの読み込み
const font = opentype.loadSync(`assets/Kaisei_Tokumin/KaiseiTokumin-Bold.ttf`);

app.listen(port, () => {
  console.log(`listening on port ${port}`);
});
index.ts (OGPリクエスト部分)
app.get(`/`, async (req, res) => {
  // express: URLクエリから描画する文字列を取得
  const title = req.query[`title`]?.toString() || `Hello, こんにちは`;
  const user = `by ` + (req.query[`user`]?.toString() || `名無しの太郎之介`);

  // SVGを生成
  const svg = `
    <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${1200}" height="${630}">
      <!-- フィルター定義 -->
      <defs>
        <!-- 影フィルター -->
        <filter id="filter1" x="-0.0164" y="-0.0312">
          <feFlood flood-opacity="0.1" flood-color="rgb(0,0,0)" result="flood" />
          <feComposite in="flood" in2="SourceGraphic" operator="in" result="composite1" />
          <feGaussianBlur in="composite1" stdDeviation="4.1" result="blur" />
          <feOffset dx="2.4" dy="2.4" result="offset" />
          <feComposite in="SourceGraphic" in2="offset" operator="over" result="composite2" />
        </filter>
      </defs>

      <!-- 背景 (灰色) -->
      <rect style="fill:#E9E9E9;" width="100%" height="100%" />

      <!-- 四角角丸 (水色) -->
      <rect
        style="fill:#F6FAFD;"
        width="1130"
        height="560"
        x="35.0"
        y="35.0"
        ry="35.0"
        filter="url(#filter1)" />
      
      <!-- 指定した文字列をSVGパスに変換 -->
      <g transform="translate(70, 70)">
        ${generateTextPath(title, 1060, 80, { align: "center", color: "#555", lines: 4 })}
      </g>
      
      <!-- ユーザー名をSVGパスに変換 -->
      <g transform="translate(70, 470)">
        ${generateTextPath(user, 1060, 64, { align: "right", color: "#ccc", lines: 1 })}
      </g>
    </svg>`;

  // sharp: SVG画像をPNG画像に変換
  const buffer = await sharp(Buffer.from(svg))
    .png()
    .toBuffer();

  // express: SVGをクライアントに返す
  res.setHeader(`Content-Type`, `image/png`);
  res.send(buffer);
});
index.ts (共通処理部分)
/**
 * 生成するテキストのオプション
 */
type TextOptions = {
  align?: `left` | `right` | `center`,
  color?: string,
  lines?: number,
}

/**
 * 指定した文字列からSVGパスを生成する
 */
function generateTextPath(text: string, width: number, lineHight: number, textOptions?: TextOptions) {
  // テキストオプションのデフォルト値を設定
  textOptions = {
    align: textOptions?.align ?? `left`,
    color: textOptions?.color ?? `#000`,
    lines: textOptions?.lines ?? 1,
  };

  // opentype: 描画オプション
  const renderOptions: opentype.RenderOptions = {};

  const columns = [``];

  // STEP1: 改行位置を算出して行ごとに分解
  for (let i = 0; i < text.length; i++) {
    // 1文字取得
    const char = text.charAt(i);

    // opentype: 改行位置を算出する為に長さを計測
    const measureWidth = font.getAdvanceWidth(
      columns[columns.length - 1] + char,
      lineHight,
      renderOptions
    );

    // 改行位置を超えている場合
    if (width < measureWidth) {
      // 次の行にする
      columns.push(``);
    }

    // 現在行に1文字追加
    columns[columns.length - 1] += char;
  }

  const paths: opentype.Path[] = [];

  // STEP2: 行ごとにSVGパスを生成
  for (let i = 0; i < columns.length; i++) {
    // opentype: 1行の長さを計測
    const measureWidth = font.getAdvanceWidth(
      columns[i],
      lineHight,
      renderOptions
    );

    let offsetX = 0;

    // 揃える位置に応じてオフセットを算出
    if (textOptions.align === `right`) {
      offsetX = width - measureWidth;
    }
    else if (textOptions.align === `center`) {
      offsetX = (width - measureWidth) / 2;
    }
    else {
      offsetX = 0;
    }

    // opentype: 1行分の文字列をパスに変換
    const path = font.getPath(
      columns[i],
      offsetX,
      lineHight * i + lineHight,
      lineHight,
      renderOptions);

    // 文字色を指定
    path.fill = textOptions.color;

    paths.push(path);
  }

  // STEP3: 指定した行数を超えていれば制限する
  if (textOptions.lines < paths.length) {
    paths.length = textOptions.lines;
  }

  // STEP4: 複数行を結合
  return paths.map(path => path.toSVG(2)).join();
}

実行例

パラメータ無しで OGP を生成すると以下のようにデフォルト値で画像が生成されます。

http://localhost:3000/

適当にパラメータを含めて OGP 画像を生成すると以下のような感じです。

http://localhost:3000/?title=Node.jsでopentype.jsを使ってOGP画像を動的に生成します&user=hikaru

なお、画像のファイルサイズは 62.6 kB で、OGP 画像の生成速度は私の開発環境で 1回目 476 ms, 2回目 379 ms, 3回目 412 ms となり大体 400 ms ぐらいでした。

(5) さらなる発展版

現在の実装では、単純に指定の幅を超えた場合に改行処理を入れているため、文章の途中で改行されて読みにくいと思います。

そこで形態素解析を行い、文章の単位ごとに区切ることで、改行位置を制御して読みやすくすることができると思います。

(6) おわりに

OGP 実装の技術選定から、具体的な実装方法までやってみました。

今回のすべてのコードは GitHub / Node.js OGP Sample にて MIT ライセンスとして公開していますのでご自由にお使いください。