yourmystar tech blog
著者: aaharu 公開日:

Deno で Open Graph 画像の生成をしてみる

この記事はユアマイスター Advent Calendar 2022の 9 日目の記事です。

画像の動的生成と @vercel/og

Open Graph のために画像を動的生成することは以前から広く行われていて、やり方を検索するとさまざまな方法で実現されています。
そんな中先日 Vercel から OG Image を動的生成するためのライブラリ @vercel/og が発表されました。
随所で WebAssembly を使用するなど @vercel/og は Edge Functions で動かすことを強く意識しているようで、高速かつ低コストに運用できるものを目指していそうです。

@vercel/og 自体は Vercel の Edge Functions でしか動作しないようですが、HTML から画像に変換する主要なロジックは Satori というライブラリが担っています。
Satori は Node.js の実行環境で動作するようなので、他の Cloudflare Workers などの Edge 環境でも動作するかもしれません。

Edge サービスの 1 つである Deno Deploy で同様のことができるか試してみました。

Deno Deploy について

Deno Deploy is a distributed system that runs JavaScript, TypeScript, and WebAssembly at the edge, worldwide.

Deno の分散ホスティングサービスで、Edge で動作します。
現時点(2022 年 12 月 9 日)でもTokyo, Osaka region があり、国内向けにも使いやすいです。

Netlify Edge Functions, Supabase Edge Functions や、先日こちらでも記事にした Slack の次世代プラットフォームのバックエンドでも使われているようです。 👉 showcase

Deno で Satori を使用する

Supabase Edge Functions の使用例に Open Graph Image Generation が既にあり、ほぼこの内容のままで良いです。
og-edge という @vercel/og に影響されて Deno に移植したライブラリを使用していて、Netlify Edge Functions 向けの記述もありますが他環境でも動作します。

Generate Open Graph images with Deno and Netlify Edge Functions, no framework needed. This is a fork of the awesome @vercel/og, ported to run on Deno.

サンプルに従い以下のようにファイルを配置します。

├── deno.json
├── import_map.json
├── handler.tsx
└── main.ts
deno.json
{
  "tasks": {
    "start": "deno run --allow-env --allow-net=unpkg.com,0.0.0.0:8000,fonts.googleapis.com,fonts.gstatic.com,cdn.jsdelivr.net main.ts",
    "watch": "deno run --allow-env --allow-net --watch main.ts"
  },
  "importMap": "./import_map.json",
  "compilerOptions": {
    "jsx": "react",
    "jsxFactory": "React.createElement",
    "jsxFragmentFactory": "React.Fragment"
  }
}
import_map.json
{
  "imports": {
    "std/": "https://deno.land/std@0.167.0/",
    "react": "https://esm.sh/react@18.2.0",
    "og_edge/": "https://deno.land/x/og_edge@0.0.4/"
  }
}
handler.tsx
import React from 'react'
import { ImageResponse } from 'og_edge/mod.ts'

const bg = `<svg xmlns= ...your svg here... </svg>`

async function loadFont(text: string) {
  const css = await (
    await fetch(`https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400&text=${encodeURIComponent(text)}`, {
      headers: {
        // to returns TTF.
        'User-Agent':
          'Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_6_8; de-at) AppleWebKit/533.21.1 (KHTML, like Gecko) Version/5.0.5 Safari/533.21.1',
      },
    })
  ).text()

  const srcIndex = css.indexOf('src: url(')
  if (srcIndex === -1) {
    throw new Error('Failed to load font')
  }
  const urlStart = css.substring(srcIndex + 'src: url('.length)
  const endIndex = urlStart.lastIndexOf(') format(')
  if (endIndex === -1) {
    throw new Error('Failed to load font')
  }

  return await (await fetch(urlStart.substring(0, endIndex))).arrayBuffer()
}

export async function handler(req: Request) {
  const { searchParams, hostname } = new URL(req.url)

  if (!searchParams.has('title')) {
    return new Response(JSON.stringify({ message: `query "title" is required.` }), {
      headers: { 'content-tyle': 'application/json; charset=UTF-8' },
      status: 400,
    })
  }

  const title = Array.from(searchParams.get('title') ?? '')
    .slice(0, 120)
    .join('')
  const fontData = await loadFont(title)

  return new ImageResponse(
    (
      <div
        style={{
          color: 'black',
          width: '100%',
          height: '100%',
          padding: '100px',
          textAlign: 'center',
          justifyContent: 'center',
          alignItems: 'center',
          display: 'flex',
          fontFamily: 'Noto Sans JP',
          fontSize: 60,
        }}
      >
        <img
          width="1200"
          height="630"
          style={{
            position: 'absolute',
          }}
          src={`data:image/svg+xml;base64,${btoa(bg)}`}
        />
        {title}
      </div>
    ),
    {
      width: 1200,
      height: 630,
      emoji: 'blobmoji',
      fonts: [
        {
          name: 'Noto Sans JP',
          data: fontData,
          weight: 400,
          style: 'normal',
        },
      ],
      headers: {
        'cache-control': hostname === 'localhost' ? 'no-cache, no-store' : 'public, max-age=604800, immutable',
      },
    },
  )
}
main.ts
import { serve } from 'std/http/server.ts'
import { handler } from './handler.tsx'

serve(handler)

deno task watch を実行してローカル環境で動作確認をすることができます。

あとはGitHub 連携deployctl でデプロイするだけです。
以下ような画像を作ることができました!

サンプルOG画像

懸念や課題

表示するテキストについて外部入力すべきか

今回は動作するか試したかっただけなので URL クエリパラメータでテキストを渡しましたが、このままだと誰でも自由に入力内容を変更することができるので悪用されたりする可能性があります。
ブログであれば記事のタイトルを WebAPI から取得するような実装の方が良いかもしれません。

Deno Deploy 単体だとレスポンスのキャッシュやストレージがない

cache-control ヘッダーを返却していますが、CDN でキャッシュを行うような仕組みは Deno Deploy 単体だとないので、リクエストがあるたびに画像の生成を行います。
一度生成した画像を保存するには、ストレージサービスを使用する必要があります。

Satori のデフォルトのままだと言語周りでフォントの問題がある

上で記載したコードは Noto Sans JP フォントの取得及び指定をしていますが、Satori は多言語対応していてデフォルトで Noto Sans を使用するので指定しなくても良さそうです。
そう思っていましたが、試していたところ問題に気づきました。

下記ツールで @vercel/og の動作を試せます。テキストによっては漢字に違和感を覚えると思います。
https://og-playground.vercel.app/

違和感のある例(問題になる漢字が多い例にしただけで意味のない文です)
違和感のあるフォントが適用された例

Satori の実装は 1 文字ごとにどの言語かを判別して読み込むフォントを判断しています。その結果漢字には Noto Sans SC が適用されてしまい、簡体字を使わない人々には適さない出力になってしまっています。

https://github.com/vercel/satori/blob/0.0.44/src/satori.ts#L120-L134
https://github.com/vercel/satori/blob/0.0.44/src/language.ts#L15-L48

他のソフトウェアでもみる問題ではありますが、その言語のネイティブスピーカーでもないとなかなか認識できない問題でもあります。こちらは既に声を挙げてくださった方がいたようで、Issue や Pull Request が作られています。
認識はされているようなのでいつか改善されると思いますが、現時点で特定の言語のテキストのみであればフォントを指定しておくのがいいでしょう。

Satori 自体まだ公開されてから日は浅く、絶賛開発中なのもあり TODO としてソースコードに記述されているものも多かったです。
本格的に使用する場合は検証やフィードバックが不可欠になりそうです。

ポストするはてなブックマークに追加シェアする