KonvaでCanvasにルビをふった文字を描画したメモ

## 概要
ルビのある文言をcanvas要素に表示したかった。
KonvaではTextを使うと、widthをみて自動的に改行をしてくれるなどの機能はあるが、ルビをふる機能はない。

ライブラリを探したところ、[Canvas Text Template v1.1](http://wiz-code.digick.jp/ajax/demo1.canvas-text-template.html)が出てきたが、これはやりたいことに対してリッチすぎた。

[konvajs.org - Rich_Text.html](https://konvajs.org/docs/sandbox/Rich_Text.html)を参考に、[html2canvas](https://html2canvas.hertzen.com/)を使用することで、ちょっと強引だが解決できた。

## 実装

[ソースコード](https://github.com/hibohiboo/cartagraph-loop-city-trpg/commit/839fd56909d4f6c76c74737aeacaa83218cbf25e)


```tsx

const Preview: React.FC<{
  form: UseFormReturn<ActionCardEditFormData>
}> = ({ form }) => {
  const card = form.getValues()
  const ref = useRef<HTMLDivElement>(null)
  const [url, setUrl] = useState('')
  const [image] = useImage(url)
  const watchFlavor = form.watch('flavor')
  useEffect(() => {
    ;(async () => {
      if (ref.current == null) return
      // そのままだとぼやけるので、2倍に出力して0.5に縮小する
      const canvas = await html2canvas(ref.current, { width: 210, scale: 2 })
      setUrl(canvas.toDataURL('img/png'))
    })()
  }, [watchFlavor])

  return (
    <div>
      <div style={{ width: '1px', height: '1px', overflow: 'hidden' }}>
        <div
          style={{
            whiteSpace: 'pre-line',
            width: '210px',
            backgroundColor: 'white',
            color: 'black',
            fontSize: '14px',
            fontFamily: family.serif,
          }}
          ref={ref}
          dangerouslySetInnerHTML={{
            __html: textToIncludeRubyTagsTextSnitized(watchFlavor),
          }}
        ></div>
      </div>
      <BaseCard>
        <CardType text="アクション" />
        <CardName name={card.name} ruby={card.nameRuby} />
        <Image x={30} y={50} image={image} scaleX={0.5} scaleY={0.5} />
        <RightBottom value="icon: Material Design icons" />
      </BaseCard>
    </div>
  )
}
```

```ts:useImage
import React from 'react'
type Status = 'loading' | 'loaded' | 'failed'
export const useImage = (
  url: string,
  crossOrigin?: 'anonymous' | 'use-credentials',
) => {
  const statusRef = React.useRef<Status>('loading')
  const imageRef = React.useRef<HTMLImageElement>()

  const [_, setStateToken] = React.useState(0)

  const oldUrl = React.useRef<string>()
  const oldCrossOrigin = React.useRef<string>()
  if (oldUrl.current !== url || oldCrossOrigin.current !== crossOrigin) {
    statusRef.current = 'loading'
    imageRef.current = undefined
    oldUrl.current = url
    oldCrossOrigin.current = crossOrigin
  }

  React.useLayoutEffect(
    function () {
      if (!url) return
      const img = document.createElement('img')

      function onload() {
        statusRef.current = 'loaded'
        imageRef.current = img
        setStateToken(Math.random())
      }

      function onerror() {
        statusRef.current = 'failed'
        imageRef.current = undefined
        setStateToken(Math.random())
      }

      img.addEventListener('load', onload)
      img.addEventListener('error', onerror)
      crossOrigin && (img.crossOrigin = crossOrigin)
      img.src = url

      return function cleanup() {
        img.removeEventListener('load', onload)
        img.removeEventListener('error', onerror)
      }
    },
    [url, crossOrigin],
  )
  return [imageRef.current, statusRef.current] as const
}
```

```ts:textToRubyTag 

import DOMPurify from 'dompurify'
const textToRubyTag = (text: string) =>
  text
    /* 半角または全角の縦棒以降の文字をベーステキスト、括弧内の文字をルビテキスト。 */
    .replace(/[|｜](.+?)（(.+?)）/g, '<ruby>$1<rt>$2</rt></ruby>')
    .replace(/[|｜](.+?)\((.+?)\)/g, '<ruby>$1<rt>$2</rt></ruby>')
    .replace(/[|｜]（(.+?)）/g, '（$1）')


export const textToIncludeRubyTagsTextSnitized = (text = '') =>
  DOMPurify.sanitize(textToRubyTag(text))
```