Nuxt ContentでOGP画像を自動生成してみた

記事ごと個別で OGP 画像を作るのが面倒だったので毎回ブログトップの OGP 画像を使いまわしていたのですが,芸がないので自動生成に切り替えました.

Node.jsにおける画像生成

Nuxt.js (Node.js) で画像を生成する方法は二つ考えられます.

一つは,JavaScript の Canvas を使って描画したものを SVG を経由して JPEG や PNG にする方法.もう一つは SVG を直接生成して JPEG や PNG を生成する方法です.

前者はジェネレータ系に見られる,ウェブサイト上でユーザが出力画像を確認できるタイプに向いています.

今回は OGP に用いる画像の自動生成を目的としており,DOMの取得や操作ができない環境で実行するので後者の方法で実現します.

実装方針

Nuxt Content の各記事ページに対応して Qiita 等に見られるようなタイトル入り OGP 向け画像の生成を最終目標とします.

  1. ベースの画像の上に文字を書く
  2. 文字列が長い場合は適切な箇所で改行する
  3. 出力画像の形式は PNG とする

タイトルの文字列は text-to-svgString から SVG に変換します.その SVG と画像の合成処理には sharp を用います.

また,日本語はスペースで単語を区切らないため,文字列が長い場合に単に文字数で改行位置を決めると見た目が残念になるという問題があります.

そのため,mikanjs で形態素解析した結果を用いて,改行位置をうまい具合に調整します.

実装

このブログは SSG を採用しており,OGP 向け画像はビルド時に一度だけ生成されれば十分です.フィードやサイトマップの自動生成と同様に yarn generate したときに画像が生成されるようにします.

今回はモジュールを使っていきます.

nuxt.config.js

{
  modules: [
    ...,
    '~/modules/imageGenerator.js'
  ]
}

モジュールに以下のように実装します.generate:done しか通らないかと思っていましたが,generate:before でも通りました.

imageGenerator.js

import mikan from "mikanjs";
import sharp from "sharp";
import TextToSVG from "text-to-svg";

// font file to use svg
const textToSVG = TextToSVG.loadSync(
  `${__dirname}/../assets/fonts/MPLUSRounded1c-Light.ttf`
);

module.exports = function () {
  this.nuxt.hook("generate:before", async (generator) => {
    // for nuxt/content
    const { $content } = require("@nuxt/content");
    const articles = await $content("blog-articles")
      .only(["slug", "title"])
      .fetch();

    // limit of characters per line
    const limitLength = 25;

    articles.forEach(async (article) => {
      // If a title is too long, break a line in splitted[breakIdx-1]
      const splitted = mikan.split(article.title);
      let titleLength = 0;
      let breakIdx = -1;
      mikan.split(article.title).some((word, i) => {
        titleLength += word.length;
        if (titleLength > limitLength) {
          breakIdx = i;
          return true;
        }
      });
      const titleLines =
        breakIdx > 0
          ? [
              splitted.slice(0, breakIdx).join(""),
              splitted.slice(breakIdx).join(""),
            ]
          : [article.title];
      
      // option for a title
      const svgOption = {
        x: 0,
        y: 0,
        fontSize: 40,
        anchor: "top",
        attributes: { fill: "black", stroke: "black" },
      };

      // generate an image buffer including only a title
      const titleBuffers = await Promise.all(
        titleLines.map(async (line) => {
          const svg = textToSVG.getSVG(line, svgOption);
          return await sharp(`${__dirname}/../assets/images/title_bg.png`)
            .resize(1200, 100)
            .composite([{ input: Buffer.from(svg) }])
            .png()
            .toBuffer();
        })
      );
      const title = titleBuffers.map((buffer, i) => {
        return { input: buffer, left: 0, top: 72 * i };
      });
      const titleBox = await sharp(`${__dirname}/../assets/images/title_bg.png`)
        .resize(1200, 100 * title.length)
        .composite(title)
        .png()
        .toBuffer();
      
      // merge a title image and a background image
      await sharp(`${__dirname}/../assets/images/ogp_bg.png`)
        .composite([{ input: titleBox }])
        .resize(1200, 630)
        .toFile(
          `${__dirname}/../static/${article.slug}.png`,
          (error) => {
            if (error) console.error(`${article.slug}: ${error}`);
          }
        );
    });
  });
};

title_bg.png は 1200x200 の透明画像,ogp_bg.png は背景画像です.透明の画像を用意せずに images[].input.create でも生成可能かもしれませんが,開発者の「それは無理やで」というコメントを見かけたので透明画像を用いました.

また,一気に合成するとタイトル位置がおかしくなったため,タイトルのみで画像のバッファを生成し,ベース画像に合成するという方法を採っています.

参考