僕だけのために開発している Togello というWebサービスで,
Goを使ってOGP画像を生成したいと開発してみたら, 思ったよりも簡単に実装できなかった.
またこういうことをやりたくなるはずなので, 未来の自分が困らないように文字の改行, 日本語フォント対応, 絵文字対応, 投稿した画像をOGP画像に合成あたりを書いていく.
自分の場合は以下の3つのpackageでやりたいことは全部できた.
2D画像を扱うGo標準のパッケージで, 画像自体のデータを保持している.
NewXxx
系で画像を作成することもでき, 位置情報やその位置の色情報などを取得することも可能.
OGP画像自体を作成するために利用した.
画像の合成機能とかを提供するGo標準のパッケージで, 画像と画像を合成したりできる.
他にも画像の拡大/縮小, 画像の切り抜きなど加工もできる.
投稿した画像や絵文字画像を加工してからOGP画像に貼り付けたりに利用した.
フォント関係の機能を提供するGo標準のパッケージで, フォントの情報を保持したり画像に文字を描画したりできる.
OGP画像に文字を描画することに利用した.
一般的に画像描画ライブラリと同様, 文字列描画の指示をした際に画像サイズを超えて描画しようとしても, いい感じに折り返してくれることはないので自分で実装する必要がある.
ただ, fontパッケージの機能だけで解決することは可能だった.
fontパッケージにはDrawerという名前の構造体がおり, こいつを生成すれば後はいい感じにできる.
MesureString()
メソッドは引数の文字列からフォントサイズも考慮して横の長さを返すDrawString()
メソッドは引数の文字列を画像に描画するDot
プロパティは, 文字列を描画する際に最初の位置を保持しており, 値を変えれば文字を描画する場所を変更できるまた, fontパッケージのFace構造体 がフォントの情報を持っており, Metrics()
メソッドを使いフォントの高さを取得可能である.
それっぽいサンプルコードはこちら
// drawerを作成
d := &font.Drawer{
Dst: img, // 文字を描画する画像情報
Src: image.NewUniform(color.RGBA{231, 233, 234, 255}), // 描画する文字の色情報
Face: face, // 描画する文字のフォントスタイルやサイズなどの情報
Dot: fixed.P(x, y), // テキスト描画の開始位置
}
// 文字列を描画した際の横の長さを取得
width := d.MeasureString("Hello World!").Ceil()
// 画像のサイズを300だとした場合, 画像のサイズを超えていないか確認
// ※絵文字対応する予定があれば, 1文字ずつはみ出していないか判定し, 1文字ずつ描画指示するロジックを作っておくと後々対応しやすい
if width > 300 {
// はみ出しちゃうので Hello だけ表示して改行処理
d.DrawString("Hello")
// フォントの縦の長さを取得
fontHeight := face.Metrics().Height.Ceil()
// X軸を初期値にして, Y軸をフォントサイズ分したに動かすことで改行のような挙動
d.Dot.X = fixed.I(0)
d.Dot.Y = d.Dot.Y + fixed.I(fontHeight)
d.DrawString("World!")
} else {
d.DrawString("Hello World!")
}
上記で font.Drawer
を生成する際に font.Face
を渡している.
ChatGPTさんは truetype.Parse(gobold.TTF)
というサンプルコードを出してくれたのだが, こちらは日本語フォントには対応していない.
なので, 自前で日本語フォントをDLしてきて, Goで読み込む必要がある.
それっぽいサンプルコードはこちら
※エラーハンドリングは省略
fontBytes, err := os.ReadFile("../internal/resources/fonts/NotoSansJP.otf")
f, err := opentype.Parse(fontBytes)
face, err := opentype.NewFace(f, &opentype.FaceOptions{
Size: fontSize,
DPI: 72,
Hinting: font.HintingFull,
})
カラー絵文字対応に関しては, 絵文字対応のフォントを上記のように読み込めばできるだと思ったが, そうはいかないようだ.
詳しいことは以下の記事がとても参考になった.
Goでカラー絵文字を使って画像を合成する
カラー絵文字〜OpenTypeフォントの仕様を中心に〜
それっぽいサンプルコードはこちら
※エラーハンドリングは省略
path := fmt.Sprintf("%s/emoji_u%.4x.png", "../internal/resources/emoji", char)
_, err := os.Stat(path)
// 絵文字フォントがない場合は, 文字を描画する
if err != nil {
d.DrawString(string(char))
}
fp, err := os.Open(path)
defer fp.Close()
emoji, _, err := image.Decode(fp)
// 絵文字画像をフォントと同じサイズに拡大/縮小させて, dstが保持する画像に書き出す
size := d.Face.Metrics().Ascent.Floor() + d.Face.Metrics().Descent.Floor()
rect := image.Rect(0, 0, size, size)
dst := image.NewRGBA(rect)
draw.CatmullRom.Scale(dst, rect, emoji, emoji.Bounds(), draw.Over, nil)
// フォントサイズに合わせた絵文字画像を大元の画像に描画し, 画像の大きさ分書き込み位置をずらす
p := image.Pt(d.Dot.X.Floor(), d.Dot.Y.Floor()-d.Face.Metrics().Ascent.Floor())
draw.Draw(img, rect.Add(p), dst, image.Point{0, 0}, draw.Over)
d.Dot.X += fixed.I(size)
Web開発畑にずっといるからか, 画像の上にAlpha値が最大値以下の黒色を配置すればいい感じにやってくれる印象があったので, そのノリでやったら失敗した.
※image.NewRGBAで作成した画像をやればうまくいったかも
1pxごとに赤色, 緑色, 青色を元画像と薄い黒色でalpha値も考慮しながら合成するという力技で実装した.
それっぽいサンプルコードはこちら
func blendColors(c1, c2 color.RGBA) color.RGBA {
alpha := float64(c2.A) / 255
newR := uint8(float64(c1.R)*(1-alpha) + float64(c2.R)*alpha)
newG := uint8(float64(c1.G)*(1-alpha) + float64(c2.G)*alpha)
newB := uint8(float64(c1.B)*(1-alpha) + float64(c2.B)*alpha)
return color.RGBA{R: newR, G: newG, B: newB, A: 255}
}
for y := 0; y < imageHeight; y++ {
for x := 0; x < imageWidth; x++ {
// 元画像のある位置の色情報を取得
originalColor := img.At(x, y).(color.RGBA)
// 色を合成. 薄い黒色で覆いたかったので薄い黒色を渡す
blendedColor := blendColors(originalColor, color.RGBA{0, 0, 0, 155})
// 合成した色である位置を塗りつぶす
img.Set(x, y, blendedColor)
}
}
Goでの画像の切り抜きをChatGPTや検索をすると, imageパッケージのSubImageが結構出てくる.
これが地獄の始まりだった.
SubImageだけを動かすとわかるのだが, 1200×400の画像があって200×200を切り抜こうとすると, 1200×400の画像が返ってきて切り抜いた部分以外は黒くなる.
僕は1200×400の画像から200×200を切り抜いた場合, 200×200の画像ができて欲しかった.
単品で動かせば気づくのだが, 僕はそのままOGP画像の貼り付けていたため, なぜか黒色になってしまう現象に陥って悩まされた.
最終的にはOGP画像に投稿画像を合成する際に欲しい部分だけ使えば良いという発想に行き着くことができ, 解決することができた.
それっぽいサンプルコードはこちら
var ogpImage *image.RGBA
var cloppedImage image.Image
// OGP画像の横の長さと同じくに拡大するには、縦の長さをどれくらいにすれば良いか算出
aspectRatio := float64(cloppedImage.Bounds().Dy()) / float64(cloppedImage.Bounds().Dx())
newHeight := int(float64(imageWidth) * aspectRatio)
// 画像を拡大
scaleImage := image.NewRGBA(image.Rect(0, 0, imageWidth, newHeight))
draw.CatmullRom.Scale(scaleImage, scaleImage.Bounds(), cloppedImage, cloppedImage.Bounds(), draw.Over, nil)
// OGP画像の長さで中央を切り取って貼り付ける
y := (scaleImage.Bounds().Dy() / 2) - (600 / 2)
draw.Draw(ogpImage, image.Rect(0, 0, 1200, 600), scaleImage, image.Pt(0, y), draw.Over)
サンプルコードの部分はかなり雑に作ってしまいました...
もし, 誰かがこの記事を読んでくださり, よくわかんねぇけど
実装に困っているから解決したいなどあれば, TwitterのDMなどでガンガン質問してください🙇
Twitterフォロー待ってます!