HugoでもTwitterCard画像を自動生成したい

最近、Twitterを眺めているとイイ感じのサムネが設定されたブログ記事を見かけるようになった。 OGP1やTwitterCardl2にはそのような生成機能が無いので各々画像を生成しているのだと思われるが、 Hugoにもそのような機能は無い。ということで、この土日にGoで画像生成するコマンドを書いた。

どう生成するか考える

Hugoは静的サイトジェネレータなので、一般的なブログサービスのように動的なことは考えず、 単純にサイト生成時に画像も生成すれば良いだろう。

では、どう生成すべきか?ImageMagicを使う方法や、Hugoでサムネ用ページを生成しそれをCode 1のように Headless Chrome を使ってスクショを撮る方法が考えられる。しかし、サムネをスクショするだけに起動するには少々重い。

docker run --rm -u $(id -u $USER) --cap-add=SYS_ADMIN --name capture -v $PWD:/workdir -w /workdir \
           justinribeiro/chrome-headless \
               --window-size=1200,628 \
               --headless \
               --disable-gpu  \
               --screenshot http://path.to/capture/target
Code 1: Headless Chromeを使ってスクリーンショットを生成する例

このサイトを生成しているGitHub Actionでも動くような、軽量でポータブルなものが欲しい。 ということで、GoでHugoの記事からサムネ画像生成するCLIを作ることにした。

GoでTwitterCardを生成する

コードを書く前に、実際のHugoページのメタ情報(FrontMatter)を使って、 「見やすくてポップな感じのTwitterCardが欲しい」 という自分のオーダに沿った最終形態をイメージした。 ここでKeynoteで下書きしたのは正解で、Goから実際に描画するときに左上からの座標やフォントサイズ、Hex Colorなどの取得に便利だった。

Figure 1: KeynoteでTwitterCardの最終形態をデザインする

Figure 1: KeynoteでTwitterCardの最終形態をデザインする

カスタマイズのしやすさ

最終形態をイメージできたので、実際に実装していく。 今後のTwitterCardの仕様変更やデザインを変えたくなった時のことを考えて、なるべくカスタマイズしやすいCLIを考えてみる。

表示したい項目(titleやtagなど)は今後変わらなそうなので固定、サイト全体で共通な背景画像はKeynoteなどの編集ツールで修正できる方が楽なのでテンプレとして読み込む。 テンプレートと合わせて変更したくなるテキストの位置やサイズ、色はパラメータで調整できると便利そうだ。

ただし、可変値のすべてをCLIのフラグや引数で指定するのは大変なのでなるべくデフォルト値を提供した上で、変更したい時だけ設定ファイルから変更を可能にしたい。 そんなこんなを検討した結果が tcardgen 。リポジトリはGitHubで公開している。

フォント、禁則処理、座標計算 etc.

Goで日本語のテキストを描画するにはフォントを読み込む必要がある。初期デザインではOpenTypeのNotoSans JP3を利用するために pkg.go.dev/golang.org/x/image OpenType4を使おうと思っていたが、未実装で利用できなかった。よって代わりとして、実装済みのTrueType5ライブラリに切り替え、KintoSans6 を利用した。

https://github.com/ladicle/tcardgen/blob/2f1753d38892e67b48321354e0a72915d7d2bb87/pkg/canvas/fontfamily/fontfamily.go#L73

また、fontパッケージには DrawString() という関数はあるが、これは単に開始位置から指定されたテキストを位置業で描画するだけである。よって、画像からはみ出さないように幅に制限を持たせ、それを越えたら別の行に描画する処理が必要になる。ただし、単純に幅で区切って折り返すと意味が取りづらくなるので、禁則処理もしたい。

今回は本文ではなくタイトルのみで出てくる言葉も限られるので、正しく構文解析をせず、ゆるい条件分岐で対応することにした。幸いなことに、同じようなことを考えていた方がいて非常に参考になった7

https://github.com/ladicle/tcardgen/blob/2f1753d38892e67b48321354e0a72915d7d2bb87/pkg/canvas/canvas.go#L66

カスタマイズしやすいよう、位置を左上を原点とした座標に対してすべてのオブジェクトの位置を設定ファイルから指定させることを考えていた。しかし、出力した画像をみるとテキストがずれている。テキストは指定した座標が Ascent の左上を示していると勘違いしていたが、実際には指定した座標はBaselineの左端を示していたようだ。

よってAscentの左上の座標を設定できるように修正した。これでKeynote等でデザインした座標をそのまま移せるようになる。

Figure 2: Font Metrics

Figure 2: Font Metrics

とか、そんなようなことを地道に実装した。(普段はインフラ系ばかりなので、CSSの実装すごいなーと思いながら簡素なline-spaceだとかbox alignとか一つずつ書いてくの面白い

更新のあった記事を対象に生成する

git diff で差分を取得したものを tcardgen にパイプすれば、更新した記事の画像のみ生成できる。 このサイトでは、この内容をデプロイスクリプトに追記している。

$ git diff --name-only HEAD^ content/post |\
    xargs tcardgen -o static/tcard -f assets/fonts/kinto-sans -t assets/template.png

Load fonts from "assets/fonts/kinto-sans"
Load template from "assets/template.png" directory
Success to generate twitter card into static/tcard/20200623_164459.png
Code 2: 更新のあった記事のTwitterCardを生成する

HugoにTwitterCard(&OGP)情報を設定する

最後に、生成した画像をサイトにOGP情報として登録する。 生成したTwitterCardはHugoのブログポストのファイル名と同じ名前で出力されるので、 {{ path.Join "tcard" (print .File.BaseFileName ".png") | absURL }} のように設定できる。 ちなみにdescriptionが空の場合、SlackでOGP情報がが展開されないので注意。長すぎてもNGなので適宜Trunkすると○。

<!-- General -->
<meta property="og:url" content="{{ .Permalink }}" />
<meta property="og:type" content="{{ if .IsHome }}website{{ else }}article{{ end }}" />
<meta property="og:site_name" content="{{ .Site.Title }}" />
<meta property="og:title" content="{{ .Title }}" />
<meta property="og:description" content="{{ with .Description -}}{{ . }}{{ else -}}{{ if .IsPage }}{{ substr .Summary 0 300 }}{{ else }}{{ with .Site.Params.description }}{{ . }}{{ end }}{{ end }}{{ end }}" />
<meta property="og:image" content="{{ if .Params.thumbnail -}}{{ .Params.thumbnail|absURL }}{{ else if hasPrefix .File.Path "post" -}}{{ path.Join "tcard" (print .File.BaseFileName ".png") | absURL }}{{ else -}}{{ "img/default.png" | absURL }}{{ end -}}" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@{{ .Site.Params.twitterName }}" />
Code 3: ブログのOGP設定

設定し終わった後は、Twitter Card Validatorを使って設定が正しいことを確認できる。

キャッシュ対策

個人ブログでOGPのキャッシュまで考慮する必要はないと思うが、位置を調べたのでメモ。 TwitterのDeveloperサイトによると1週間でOGPのメタ情報を再スクレイプされるらしいが、 TwitterCardの画像は更新されない場合があるらしい。

ワークアラウンドとしては、画像更新のたびにユニークな値を画像URLの末尾にパラメータとして付与をする方法が提案されている。 Hugoであれば、最終更新日を利用すると良さそうだ。

xxx.png?{{ .Lastmod.Format "20060102150405" }}
.