Msgboxを生成するWebアプリを作った話 (ブラウザ上で画像生成)

下のような画像を生成するWebアプリを作りました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/637875/aec9503f-734e-dab4-c048-031131339255.png)

↓成果物

https://laddge.github.io/msgbox-maker/


## 前提条件
- サーバーを用意するのは面倒だから、ブラウザ上で完結させてGitHub Pagesで公開する
- 自力で文字の折り返しを実装するのは面倒なので、そこらへんをいい感じにできるプラグインを使う
- 今回は小規模のプロジェクトなので、[svelte](svelte.dev)を使って作ります。

## 実装
入力した文字列から画像を生成するとなると、どうしても文字列の折り返しという問題がつきまといます。

どういうことかというと、例えばsvgにそのまま文字列を入れると、枠より長い文章は枠を突き抜けて無限大に発散します。

![Untitled38_20230628100407.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/637875/c8efb982-2e19-066e-59dc-3267142a3eef.png)
↑こういうイメージ

これの対策として文字数で文章を区切って折り返すという方法がありますが、これだと英字と日本語文字が混ざると幅が変わってしまいます。

ほかにも、一度ダミー画像に出力してその幅を計算して区切るというやりかたもありますが、自分で実装するとなるとかなりめんどくさいです。(以前やりました)

そこで、htmlで書いたものを画像化できれば、うまく折り返せると考えました。

![Untitled38_20230628101328.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/637875/23ea144e-942e-8472-a1f7-db5c20a026b6.png)
↑こういう流れで変換していきます。

### html → svg

[vercel/satori](https://github.com/vercel/satori)を使います。

https://github.com/vercel/satori

```
pnpm add -D satori satori-html
```

satoriを使うと、htmlからsvgを生成できます。

ogp用の画像を生成するのに使う人が多いみたいです。

↓サンプルコード

```jsx:api.jsx
import satori from 'satori'

const svg = await satori(
  <div style={{ color: 'black' }}>hello, world</div>,
  {
    width: 600,
    height: 400,
    fonts: [
      {
        name: 'Roboto',
        // Use `fs` (Node.js only) or `fetch` to read the font as Buffer/ArrayBuffer and provide `data` here.
        data: robotoArrayBuffer,
        weight: 400,
        style: 'normal',
      },
    ],
  },
)
```

satoriはvercelのプロジェクトなだけあって、Next.js(React)と相性がいいように作られています。

1つ目の引数に[JSXかReact-elements-like objects](https://reactjs.org/docs/introducing-jsx.html)を、2つ目の引数に縦横のサイズとフォントのオプションを指定すると、svgの文字列を返します。

:::note warn
satoriで使えるhtml, cssには制限があるので、[ドキュメント](https://github.com/vercel/satori#documentation)を確認してください。
:::

このライブラリのすごいところは、ブラウザ上でも動作するというところです。

今回はsvelteでプロジェクトを作るので、JSXは使えません。

そこで、[satori-html](https://github.com/natemoo-re/satori-html)を使うことでhtmlからReactElementを生成して使います。

https://github.com/natemoo-re/satori-html

<details><summary>完成したコード</summary>

```ts:genSvg.ts
import satori from 'satori'
import { html } from 'satori-html'
import ErrorIcon from './assets/dialog-error.png'
import InfoIcon from './assets/dialog-info.png'
import QuestionIcon from './assets/dialog-question.png'
import WarningIcon from './assets/dialog-warning.png'
import NotoSansJP from './assets/NotoSansJP-Regular.otf'

const buttons = [
  ['OK'],
  ['OK', '<span style="transform: scaleX(.8);">キャンセル</span>'],
  ['<span style="transform: scaleX(.8);">中止</span>(A)', '<span style="transform: scaleX(.8);">再試行</span>(R)', '<span style="transform: scaleX(.8);">無視</span>(I)'],
  ['<span style="transform: scaleX(.8);">はい</span>(Y)', '<span style="transform: scaleX(.8);">いいえ</span>(N)', '<span style="transform: scaleX(.8);">キャンセル</span>'],
  ['<span style="transform: scaleX(.8);">はい</span>(Y)', '<span style="transform: scaleX(.8);">いいえ</span>(N)'],
  ['<span style="transform: scaleX(.8);">再試行</span>(R)', '<span style="transform: scaleX(.8);">キャンセル</span>'],
]

export const genSvg = async (width: number, height: number, title: string, text: string, button: number, icon: number) => await satori(
  html(`
    <div tw="flex flex-col h-full bg-white border border-blue-500">
      <div tw="flex text-xl p-3">
        <div class="grow flex pl-1">
          ${title}
        </div>
        <img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCAyNCAyNCIgc3Ryb2tlV2lkdGg9IjEuNSIgc3Ryb2tlPSJjdXJyZW50Q29sb3IiPjxwYXRoIHN0cm9rZUxpbmVjYXA9InJvdW5kIiBzdHJva2VMaW5lam9pbj0icm91bmQiIGQ9Ik02IDE4TDE4IDZNNiA2bDEyIDEyIiAvPjwvc3ZnPgo=" tw="w-8 h-8 block" />
      </div>
      <div tw="grow flex text-xl px-8">
        <div tw="flex items-center">
          ${icon ? '<img src="' + [ErrorIcon, InfoIcon, QuestionIcon, WarningIcon][icon - 1] + '" tw="h-14 mr-4" />' : ''}
        </div>
        <div tw="grow flex flex-wrap text-xl py-16">
          ${text.replace(/\n/g, '<div tw="w-full flex" />')}
        </div>
      </div>
      <div tw="flex justify-end bg-[#f1f1f1] text-xl p-6">
        <div tw="flex border border-black w-32 ml-3">
          <div tw="flex justify-center border-2 border-t-[#e6e6e6] border-r-[#898989] border-b-[#898989] border-l-[#e6e6e6] w-full">
            ${buttons[button][0]}
          </div>
        </div>
        ${buttons[button].slice(1).map(btn => '\
        <div tw="flex border w-32 ml-3">\
          <div tw="flex justify-center border-2 border-t-[#e6e6e6] border-r-[#898989] border-b-[#898989] border-l-[#e6e6e6] w-full">\
            ' + btn + '\
          </div>\
        </div>'
        ).join('')}
      </div>
    </div>
  `),
  {
    width,
    height,
    fonts: [
      {
        name: 'NotoSansJP',
        data: await fetch(NotoSansJP).then(res => res.arrayBuffer()),
        weight: 400,
        style: 'normal',
      },
    ],
  },
)
```

</details>

### svg → canvas → png

ブラウザ上でsvgをpngに変換するなら、一度`<canvas>`上に描画してpngとして保存するのがシンプルでいいでしょう。

標準で用意されている`ctx.drawImage()`関数でもできますが、Firefoxだとうまく動かなかったり、少し信頼性に欠けるので、これもライブラリを使ってしまいます。

↓参考

https://qiita.com/sassy_watson/items/5193bc55357fd82f41ec

今回は上の記事と同じく[canvg](https://github.com/canvg/canvg)というライブラリを使います。

https://github.com/canvg/canvg

上の参考記事の時点とcanvgのバージョンが違うので若干書き方が異なります。

```
pnpm add -D canvg
```

```js
const svg = `<svg>...</svg>`
const canvas = document.querySelector('canvas')
const ctx = canvas.getContext('2d')
const v = Canvg.fromString(ctx, svg)

await v.render()
```

これで`canvas`上に描画されます。

さらに、これをpngに変換するには、JavaScript標準の[`toDataURL()`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toDataURL)を使います。

```js
const dataURL = canvas.toDataURL()
// 'data:image/png;base64,...'
```

これでpng画像のbase64なURLを取り出すことができ、img要素のsrc属性に直接この文字列を渡せば、png画像として表示されます。

<details><summary>完成したコード</summary>

```html:RenderPng.svelte
<script lang="ts">
  export let width: number,
    height: number,
    title: string,
    text: string,
    button: number,
    icon: number

  import { genSvg } from './genSvg'
  import { Canvg } from 'canvg'

  let src: string = ''

  $: {
    if (typeof window !== 'undefined') {
      (async () => {
        const svg = await genSvg(width, height, title, text, button, icon)
        const canvas = document.querySelector('canvas')
        canvas.width = width
        canvas.height = height
        const ctx = canvas.getContext('2d')
        const v = Canvg.fromString(ctx, svg)

        await v.render()
        src = canvas.toDataURL()
      })()
    }
  }
</script>

<canvas class="hidden" />
{#if src}
  <img {src} alt="generated png" class="w-full" />
{/if}
```

</details>

## 完成
その他の画面の実装などは割愛します。

完成品は↓のリポジトリにあります。

https://github.com/laddge/msgbox-maker

`Github Actions`を使って`Github Pages`上で公開しています。

https://laddge.github.io/msgbox-maker

サーバーを用意しなくても簡単な画像生成ができるのは画期的で面白いですよね。

以上です。