SatoriとExpressで動的な画像をJSXで作成する

# はじめに
こんにちは、いなたつ [@inatatsu_csg](https://qiita.com/inatatsu_csg) です。
Symbolブロックチェーン上のトランザクションをWebアプリケーションから受け取り、署名を行いWebアプリケーションへと返却するブラウザ拡張機能[SSS Extension](https://chromewebstore.google.com/detail/sss-extension/llildiojemakefgnhhkmiiffonembcan?hl=ja)の開発や[株式会社 OpeningLine](https://www.opening-line.co.jp/)でSymbolブロックチェーンを用いたWebアプリケーションの開発や、Symbolブロックチェーンを用いたアプリケーション開発に関する書籍 [実践Symbol](https://amzn.asia/d/g8H4Wmp)の執筆などを行っています。

今回は、動的な画像を生成するサーバの作成をExpressとsatoriを使って作る方法を解説します。

# 使用技術
- React : JSX記法で画像を記述します
- satori : JSXからsvgを生成します
- sharp : pngにデータを変換する際に使用します
- fs : フォントデータを読み込むために使用します
- express : 画像を配信するためのサーバになります

デプロイ先がVercelやFirebaseFunctionsになる場合は使用するインフラに合わせてexpressの処理を書き換えるとその他のデプロイ環境にも対応可能です。

## satori
https://www.npmjs.com/package/satori
satoriはNext.jsの開発元であるVercelがOG画像を生成するために開発・メンテナンスを行なっているライブラリです。
satoriを使うことでHTML/CSSやJSXからSVG画像を生成することができます。

## sharp
https://www.npmjs.com/package/sharp
sharpは画像のリサイズや透過等の画像処理を行うことができるライブラリです。
sharpを使うことで、satoriで生成したSVG画像をPNGに変換することができます。

# 今回の目標
以下のようなURLでリクエストを投げると、入力に応じた画像が返ってきます。
今回は簡単に入力値を表示するだけになりますが、外部からデータ取得などを行うことにより、より高度な画像を生成することも可能です。
http://localhost:3000/image?firstName=inagaki&lastName=tatsuhiro&handleName=inatatsu&job=ApplicationEnginner
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/269859/34c1c150-07af-305e-c869-edc34eceda57.png)

# 作るもの
名前とハンドルネームそして職業を送ると簡易なネームプレート画像を生成し返却するサーバ

クエリパラメタとして以下の項目を指定することでネームプレート画像が生成されます。
- firstName
- lastName
- handleName
- job

# プログラムの解説

全体としては、シンプルにExpressでリクエストを受けて、画像を返却するだけのサーバとなっています。

<details>
<summary>プログラム全体</summary>

```tsx
import satori from 'satori'
import sharp from 'sharp'
import fs from 'fs'
import React, { CSSProperties } from 'react'
import express, { Request } from 'express'

const roboto = fs.readFileSync('./fonts/Roboto.ttf')

const app = express()

app.get('/', (req, res) => {
  res.send('hello')
})

const width = 800
const height = 256

const option = {
  width,
  height,
  fonts: [
    {
      name: 'Roboto',
      data: roboto
    }
  ]
}

interface Style {
  [key: string]: CSSProperties
}

const s: Style = {
  root: {
    width: '100%',
    height: '100%',
    display: 'flex',
    justifyContent: 'center',
    alignItems: 'center',
    background:
      'linear-gradient(136deg, #2A74E2 13.85%, #2AA0E2 85.74%), linear-gradient(0deg, #2A74E2 0%, #2A74E2 100%)'
  },
  content: {
    width: `${width - 40}px`,
    height: `${height - 40}px`,
    padding: '32px',
    display: 'flex',
    flexDirection: 'column',
    background: 'white',
    borderRadius: '32px'
  },
  nameWrapper: {
    display: 'flex',
    marginBottom: '24px',
    flexDirection: 'column'
  },
  name: {
    fontSize: '48px'
  },
  handleName: {
    fontSize: '24px'
  },
  other: {
    display: 'flex',
    fontSize: '36px'
  }
}

interface ExReq extends Request {
  query: {
    firstName: string
    lastName: string
    handleName: string
    job: string
  }
}
app.get('/image', async (req: ExReq, res) => {
  const body = (
    <div style={s.root}>
      <div style={s.content}>
        <div style={s.nameWrapper}>
          <div style={s.name}>
            {`${req.query.firstName} ${req.query.lastName}`}
          </div>
          <div style={s.handleName}>{req.query.handleName}</div>
        </div>
        <div style={s.other}>{req.query.job}</div>
      </div>
    </div>
  )

  const svg = await satori(body, option)

  const pngBuffer = await sharp(Buffer.from(svg)).png().toBuffer()
  res.type('png')
  res.send(pngBuffer)
})

app.listen({ port: 3000 }, () => {
  console.log(`Server ready at http://localhost:3000`)
})

export default app

```

</details>

まずは、インポートなど、下準備です。
```ts
import satori from 'satori'
import sharp from 'sharp'
import fs from 'fs'
import React, { CSSProperties } from 'react'
import express, { Request } from 'express'

const roboto = fs.readFileSync('./fonts/Roboto.ttf')
```

とりあえず疎通確認、http://localhost:3000/ にアクセスして「hello」と表示されるとExpressが起動していることが確認できます。
```ts
const app = express()

app.get('/', (req, res) => {
  res.send('hello')
})

app.listen({ port: 3000 }, () => {
  console.log(`Server ready at http://localhost:3000`)
})

export default app
```

satoriで生成する画像のサイズやフォントなどのオプションを定義しています。
```ts
const width = 800
const height = 256

const option = {
  width,
  height,
  fonts: [
    {
      name: 'Roboto',
      data: roboto
    }
  ]
}

```

後ほど、こんな感じでJSXと生成オプションをsatoriに投げるとsvgが生成されます。
```ts
const svg = await satori(body, option)
```

ここはスタイル定義です。
Styleインターフェースを定義し、スタイル情報をobjectで管理できるようにします。flexにしろとよく怒られます。とりあえず困ったらflex突っ込んだらいいです。

Styleインターフェースは、[インデックスシグネチャ](https://typescriptbook.jp/reference/values-types-variables/object/index-signature)を利用し、すべてのプロパティに対してCSSProperties型を当てはめています。これにより、実際にスタイルを定義する際に、定義するスタイルの種類に関心を持つことなく、全てに型を反映させることができます。
```ts
interface Style {
  [key: string]: CSSProperties
}

const s: Style = {
  root: {
    width: '100%',
    height: '100%',
    display: 'flex',
    justifyContent: 'center',
    alignItems: 'center',
    background:
      'linear-gradient(136deg, #2A74E2 13.85%, #2AA0E2 85.74%), linear-gradient(0deg, #2A74E2 0%, #2A74E2 100%)'
  },
  content: {
    width: `${width - 40}px`,
    height: `${height - 40}px`,
    padding: '32px',
    display: 'flex',
    flexDirection: 'column',
    background: 'white',
    borderRadius: '32px'
  },
  nameWrapper: {
    display: 'flex',
    marginBottom: '24px',
    flexDirection: 'column'
  },
  name: {
    fontSize: '48px'
  },
  handleName: {
    fontSize: '24px'
  },
  other: {
    display: 'flex',
    fontSize: '36px'
  }
}
```

ExpressのRequest型を継承して、queryの型情報を追加し拡張したRequest型であるExReq型を定義しています。
```ts
interface ExReq extends Request {
  query: {
    firstName: string
    lastName: string
    handleName: string
    job: string
  }
}
```
Expressのそれ
```ts
app.get('/image', async (req: ExReq, res) => {
  // do something
})
```

描画する画像をJSXで作成しています。
今回は、queryで指定した、名前等の情報を素直に配置しているだけです。
```tsx
  const body = (
    <div style={s.root}>
      <div style={s.content}>
        <div style={s.nameWrapper}>
          <div style={s.name}>
            {`${req.query.firstName} ${req.query.lastName}`}
          </div>
          <div style={s.handleName}>{req.query.handleName}</div>
        </div>
        <div style={s.other}>{req.query.job}</div>
      </div>
    </div>
  )
```

satoriでSVGを生成します。
```ts
const svg = await satori(body, option)
```

sharpを使って、生成したSVGをPNGに変換します。
```ts
const pngBuffer = await sharp(Buffer.from(svg)).png().toBuffer()
```
resに詰め込んでブラウザに返します。
```ts
  res.type('png')
  res.send(pngBuffer)
```

# ユースケース
resにキャッシュする時間等の指定を追加し、入力された情報から外部のデータを取得するなどし、リアルタイムなデータを反映させた画像を生成することも可能です。

僕は、自分のブロックチェーンアドレスを入力し、残高を表示した画像をGitHubのプロフィールページに記載するとかやったりしました。

以下の画像は僕が一時期GitHubのプロフィールに設定していたブロックチェーンのアドレスを入れると所持している残高と、持っているその他のトークンの種類数をリアルタイムの情報で表示する画像です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/269859/bc694c3f-c5a1-61c2-287a-a7a3cdceb6e7.png)


また、この技術は動的OGPの生成にもよく使われます。

# おわりに
今回は、satoriとsharpで生成した画像をExpressで返却するサーバの作成を行いました。このようにして、OG画像の生成やGitHubのREADMEやプロフィールに動的な画像を埋め込むことができます。