Goでカラー絵文字を使って画像を合成する

[Gopher道場アドベントカレンダー](https://qiita.com/advent-calendar/2018/gopherdojo)の13日目の記事です。

## 概要

この記事ではGo用いてカラー絵文字を含んだテキストをレンダリングする方法について説明します。
最終的には以下のような画像を出力するのを目標とします。

![test.jpg](https://qiita-image-store.s3.amazonaws.com/0/37916/9db2ff6e-ab22-f789-bdb5-892203d689cb.jpeg)


## フォントの読み込み

今回は以下のパッケージを用いてTrueType フォントを読み込みます[^1]。

- [github.com/golang/freetype/truetype](http://github.com/golang/freetype/truetype)

まず `truetype.Parse` に byte スライスを渡します。
ここでは準標準パッケージに含まれている [gobold](https://godoc.org/golang.org/x/image/font/gofont/gobold) を使います。

~~~go
ft, err := truetype.Parse(gobold.TTF)
if err != nil {
    fmt.Fprintln(os.Stderr, err)
    os.Exit(exitFailure)
}
~~~

フォントをレンダリングする際のオプションを設定します。
各値に0を指定するとデフォルト値が使われます。

~~~go
opt := truetype.Options{
    Size:              fontSize,
    DPI:               0,
    Hinting:           0,
    GlyphCacheEntries: 0,
    SubPixelsX:        0,
    SubPixelsY:        0,
}
~~~

`truetype.NewFace()` を使って `font.Face` を取得します。
この変数を後から `draw.Drawer` へ渡すことになります。

~~~go
face := truetype.NewFace(ft, &opt)
~~~

## 絵文字の読み込み

カラー絵文字は OpenType の拡張を使っていますが、
プラットフォームによって使っている規格がバラバラです[^2]。
残念ながらカラー絵文字を含む OpenType に対応したライブラリは Go にはありません．

元のデータとしては SVG なり PNG なりの画像なので、
今回は [Noto Emoji](https://github.com/googlei18n/noto-emoji) に収録されている PNG ファイルを利用します。

`rune` 型の `r` が与えられたとき、ファイル名は以下のようになります。

~~~go
path = fmt.Sprintf("%s/emoji_u%.4x.png", imagePath, r)
~~~

`image.Decode()` によって `image.Image` 型に変換します。
また、フォントの高さと絵文字のサイズを合わせるためにリサイズしておきます。
Metrics については[こちら](https://godoc.org/golang.org/x/image/font#Metrics)を参考にしてください．

~~~go
fp, err := os.Open(path)
if err != nil {
        fmt.Fprintln(os.Stderr, err)
        continue
}
defer fp.Close()

emoji, _, err := image.Decode(fp)
if err != nil {
        fmt.Fprintln(os.Stderr, err)
        continue
}

// フォントのサイズと絵文字のサイズを合わせるためにリサイズ
size := dr.Face.Metrics().Ascent.Floor() + dr.Face.Metrics().Descent.Floor()
rect := image.Rect(0, 0, size, size)
dst := image.NewRGBA(rect)
draw.ApproxBiLinear.Scale(dst, rect, emoji, emoji.Bounds(), draw.Over, nil)
~~~

## 画像の合成

まず `font.Drawer` を作成します。
描画先の `image.Image` と `truetype.NewFace()` で取得した `font.Face` を使います。

~~~go
img := image.NewRGBA(image.Rect(0, 0, imageWidth, imageHeight))

dr := &font.Drawer{
    Dst:  img,
    Src:  image.White,
    Face: face,
    Dot:  fixed.Point26_6{},
}
~~~

以下のようにしてカラー絵文字と通常の文字を描画することができます。


~~~go
text := "Hello, world! 👋"

// 描画の初期位置
dr.Dot.X = (fixed.I(imageWidth) - dr.MeasureString(text)) / 2
dr.Dot.Y = fixed.I(textTopMargin)

for _, r := range text {
        path := fmt.Sprintf("%s/emoji_u%.4x.png", imagePath, r)
        _, err = os.Stat(path)

        // 画像ファイルが存在する場合に err == nil
        if err == nil {
                /* 
                  前述した絵文字の読み込みとリサイズの処理...
                */
                // font.Drawer.Dot はグリフの baseline の座標を指している (だいたいグリフの左下の地点)
                // 一方 draw.Draw で画像を描画する場合には左上の座標が必要になる
                p := image.Pt(dr.Dot.X.Floor(), dr.Dot.Y.Floor()-dr.Face.Metrics().Ascent.Floor())
                draw.Draw(img, rect.Add(p), dst, image.ZP, draw.Over)
                dr.Dot.X += fixed.I(size)
        } else {
                // 対応するカラー絵文字が存在しないので TrueType フォントを使用する
                dr.DrawString(string(r))
        }
}
~~~

## まとめ

プログラム全体はこのようになります。
説明の都合上、`main` にベタ書きしています。

~~~emoji.go
package main

import (
	"bytes"
	"fmt"
	"image"
	"image/jpeg"
	_ "image/png"
	"io"
	"os"

	"github.com/golang/freetype/truetype"
	"golang.org/x/image/draw"
	"golang.org/x/image/font"
	"golang.org/x/image/font/gofont/gobold"
	"golang.org/x/image/math/fixed"
)

const (
	exitSuccess = 0
	exitFailure = 1
)

const (
	imagePath     = "./noto-emoji/png/128"
	fontSize      = 64  // point
	imageWidth    = 640 // pixel
	imageHeight   = 120 // pixel
	textTopMargin = 80  // fixed.I
)

func main() {
	// TrueType フォントの読み込み
	ft, err := truetype.Parse(gobold.TTF)
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(exitFailure)
	}

	opt := truetype.Options{
		Size:              fontSize,
		DPI:               0,
		Hinting:           0,
		GlyphCacheEntries: 0,
		SubPixelsX:        0,
		SubPixelsY:        0,
	}

	img := image.NewRGBA(image.Rect(0, 0, imageWidth, imageHeight))
	face := truetype.NewFace(ft, &opt)

	dr := &font.Drawer{
		Dst:  img,
		Src:  image.White,
		Face: face,
		Dot:  fixed.Point26_6{},
	}

	text := "Hello, world! 👋"

	// 描画の初期位置
	dr.Dot.X = (fixed.I(imageWidth) - dr.MeasureString(text)) / 2
	dr.Dot.Y = fixed.I(textTopMargin)

	// 一文字ずつ描画していく
	for _, r := range text {
		path := fmt.Sprintf("%s/emoji_u%.4x.png", imagePath, r)
		_, err = os.Stat(path)

		// 画像ファイルが存在する場合に err == nil
		if err == nil {
			// 画像の読み込み
			fp, err := os.Open(path)
			if err != nil {
				fmt.Fprintln(os.Stderr, err)
				continue
			}
			defer fp.Close()

			emoji, _, err := image.Decode(fp)
			if err != nil {
				fmt.Fprintln(os.Stderr, err)
				continue
			}

			// フォントのサイズと絵文字のサイズを合わせるためにリサイズ
			size := dr.Face.Metrics().Ascent.Floor() + dr.Face.Metrics().Descent.Floor()
			rect := image.Rect(0, 0, size, size)
			dst := image.NewRGBA(rect)
			draw.ApproxBiLinear.Scale(dst, rect, emoji, emoji.Bounds(), draw.Over, nil)

			// font.Drawer.Dot はグリフの baseline の座標を指している (だいたいグリフの左下の地点)
			// 一方 draw.Draw で画像を描画する場合には左上の座標が必要になる
			p := image.Pt(dr.Dot.X.Floor(), dr.Dot.Y.Floor()-dr.Face.Metrics().Ascent.Floor())
			draw.Draw(img, rect.Add(p), dst, image.ZP, draw.Over)
			dr.Dot.X += fixed.I(size)
		} else {
			// 対応するカラー絵文字が存在しないので TrueType フォントを使用する
			dr.DrawString(string(r))
		}
	}

	// JPEG に変換して stdout に出力
	buf := &bytes.Buffer{}
	err = jpeg.Encode(buf, img, nil)
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(exitFailure)
	}

	_, err = io.Copy(os.Stdout, buf)
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(exitFailure)
	}
}
~~~

実行する場合には上記のコードをコピペして、以下のようにします。

~~~bash
$ git clone https://github.com/googlei18n/noto-emoji.git
$ go run emoji.go > hello.jpg
~~~

## 注意点

- [alias](https://github.com/googlei18n/noto-emoji/blob/master/emoji_aliases.txt) を考慮していない
    - 例えば U+1F64B は U+1F64B U+200D U+2640 と同じ画像を使用するように指示がある (emoji_u1f64b_200d_2642.png というファイルはあるが emoji_u1f64b.png は存在しない)
    - aliasに対応したバージョンは[ここ](https://gist.github.com/uobikiemukot/3e9d4f80fd34d524797a0519235632af)にあります
- 結合絵文字に対応していない
    - 複数の Unicode Codepoint を用いて1つの絵文字を表すもの (👩‍🚀 や 🇯🇵 や 👧🏽 など)
    - 大変なのでやっていません :innocent: 


[^1]: 準標準パッケージの [godoc.org/golang.org/x/image/font/opentype](https://godoc.org/golang.org/x/image/font/opentype) というのもあるのですが、使ってみたところ `panic: not implemented` というエラーが出たので断念しました…。
[^2]: 詳細はこの[記事](https://qiita.com/496_/items/6910dd76283ba1347327)をご覧ください。