先日このブログのデザインリニューアルとドメイン変更(bizドメインからcomドメイン)とレンサバからCloudflare pagesに移行しました。

リニューアル前からCMSはmicroCMS、フレームワークはAstroで実装していて、これは変えたくなかったためそのままにしました。

ブログ記事もまだまだ全然少ない状態ではあるのですが、サムネイルを作るのが最近手間だなと感じていて、、、更新頻度も少ないので作れば良いのですがそこまでああしたいこうしたいもないので、テンプレートとなる背景にmicroCMSから取得したタイトルを乗っけるような形で自動生成してみようと思い、実際にこのブログで実装してみました。

もっと良い方法はあるのかもしれませんが、一旦今回取った方法を記載しておきます。

環境

念の為実装時の環境も記載しておきます。

CMS・・・microCMS
フレームワーク・・・Astro v5.0.2

microcms-js-sdkもインストールしておきます。

npm install microcms-js-sdk

実装方法

サムネイルのテンプレートとなる画像の用意

下記のようなテンプレートとなる(背景画像)画像を用意します。

ライブラリのインストール

下記のライブラリをインストールします。


sharp

npm install sharp

fs-extra

npm install fs-extra

canvas

npm install canvas

microcms.tsの編集

src/library/microcms.ts

このファイルに下記のように追記します。

import sharp from 'sharp'
import fs from 'fs-extra'
import { createCanvas } from 'canvas'
import path from 'path'

// 型定義やAPI呼び出しの関数等は省略

// サムネイル生成関数の型定義
interface GenerateThumbnailParams {
  title: string
  outputFilePath: string
}

// 改行位置等が気になったりしたので、英単語と日本語を考慮して分割する関数
const splitTextByWidth = (text: string, maxWidth: number, font: string): string[] => {
  const canvas = createCanvas(1, 1) // 仮のキャンバス作成
  const context = canvas.getContext('2d')
  context.font = font // フォントの設定

  const lines: string[] = []
  let currentLine = ''

  // 正規表現で単語と日本語文字を分割
  const tokens = text.match(/[\u3040-\u30FF\u4E00-\u9FAF]|[a-zA-Z]+|./g) || []
  
  // 分割された各トークンを処理
  for (const token of tokens) {
    // 現在の行にトークンを追加した場合の仮の行を作成
    const testLine = currentLine ? `${currentLine}${token}` : token
    // 仮の行の幅を計測
    const testLineWidth = context.measureText(testLine).width

    // 仮の行が最大幅を超える場合
    if (testLineWidth > maxWidth) {
      // 現在の行が空なら、トークンをそのまま行として追加
      if (currentLine === '') {
        lines.push(token)
        currentLine = '' // 次の行の処理に備えて初期化
      } else {
        // 現在の行を追加し、新しい行を開始
        lines.push(currentLine)
        currentLine = token
      }
    } else {
      // 仮の行の幅が収まる場合、現在の行にトークンを追加
      currentLine = testLine
    }
  }

  // 最後の行が空でなければ追加
  if (currentLine) {
    lines.push(currentLine)
  }

  return lines
}

// サムネイルを生成する関数
async function generateThumbnail({ title, outputFilePath }: GenerateThumbnailParams): Promise<void> {
  const templatePath = './public/img-thumb-base.png' // テンプレートの背景画像のパス

  try {
    // 出力先のディレクトリがなかった場合は作成する
    const outputDir = outputFilePath.substring(0, outputFilePath.lastIndexOf('/'))
    await fs.ensureDir(outputDir)

    // テキストが640pxに収まるように分割
    const font = '40px Noto Sans JP' // 使用するフォントとサイズ(幅計算のため)
    const lines = splitTextByWidth(title, 640, font) // 分割された行
    const lineHeight = 72 // 行間の調整の数値

    // SVGでテキストを作成
    const textSvg = `
      <svg width="800" height="495" xmlns="http://www.w3.org/2000/svg">
        <style>
          /* タイトルのフォントスタイル */
          .title {
            font-size: 40px;
            font-family: Noto Sans JP;
            font-weight: bold;
            fill: white; /* テキストの色 */
          }
        </style>
        <!-- サイズと背景色を設定する -->
        <rect width="800" height="495" fill="transparent" />
        <!-- 描画 -->
        ${lines
          .map(
            (line, index) =>
              `<text x="60" y="${400 - (lines.length - index - 1) * lineHeight}" class="title">${line}</text>`
          )
          .join('\n')}
      </svg>
    `
    const textBuffer = Buffer.from(textSvg) // SVGをバッファとして保持

    // 背景画像にSVGを合成
    await sharp(templatePath)
      .composite([{ input: textBuffer, blend: 'over' }])
      .toFile(outputFilePath)

    console.log(`サムネイル生成成功: ${outputFilePath}`)
  } catch (error) {
    console.error('サムネイル生成中にエラー発生:', error)
  }
}

// サムネイルを自動で生成する関数
async function generateBlogThumbnails() {
  try {
    // microCMSクライアントの作成
    const client = createClient({
      serviceDomain: import.meta.env.PUBLIC_MICROCMS_SERVICE_DOMAIN,
      apiKey: import.meta.env.PUBLIC_MICROCMS_API_KEY
    })

    // ブログ記事を取得
    const blogs = await client.get({
      endpoint: 'blogs', // エンドポイント(例なので変更してください)
      queries: {
        limit: 1000 // 取得する記事数(必要に応じて調整)
      }
    })

    // サムネイル保存先ディレクトリ(public/にしていますが、src/でもOK)
    const thumbnailDir = './public/_assets/img'
    await fs.ensureDir(thumbnailDir)

    // 各記事のサムネイルを生成
    for (const blog of blogs.contents) {
      // サムネイルのファイル名を記事IDから生成
      const thumbnailPath = path.join(thumbnailDir, `${blog.id}.webp`)

      // すでにサムネイルが存在する場合はスキップ(必要に応じてコメントアウト)
      if (await fs.pathExists(thumbnailPath)) {
        console.log(`サムネイル already exists: ${blog.id}`)
        continue
      }

      // サムネイル生成
      await generateThumbnail({
        title: blog.title,
        outputFilePath: thumbnailPath
      })

      console.log(`サムネイル自動生成完了: ${blog.id}`)
    }
  } catch (error) {
    console.error('サムネイル自動生成中にエラー発生:', error)
  }
}

// サムネイル生成の実行
;(async () => {
  try {
    await generateBlogThumbnails()
  } catch (error) {
    console.error('エラー:', error)
  }
})()

// ビルド時や定期的に実行する
export async function generateAllThumbnails() {
  await generateBlogThumbnails()
}

できる限り何をしているのかわかりやすくコメントを入れてみました。

再生成等が必要な場合は下記の部分をコメントアウトすれば再生成されます。

// すでにサムネイルが存在する場合はスキップ(必要に応じてコメントアウト)
if (await fs.pathExists(thumbnailPath)) {
  console.log(`サムネイル already exists: ${blog.id}`)
  continue
}

SVGのサイズ設定とテンプレートの画像のサイズが一致していないとエラーになります。

管理画面から投稿するのは管理者という想定でエスケープ処理等は入れていません。必要に応じて入れたほうがいいかもしれません。

上記のように設定してこのブログのようなサムネイルを生成することができました。

タイトルの付け方のルール等をしっかりと決めれば、コードの改修は必要ですが改行位置をきれいにすることもできると思います。

冒頭にも下記ましたが、この方法以外にももっと簡単にできる方法はあるとは思います。とはいえ、参考になると幸いです。

(ただ、microCMSに登録したサムネイルを取得しなくなったので、Wappalyzerで「microCMS」が表示されなくなったのが淋しいです・・・)