🌐

Go でアラビア語、ヒンディー語、タイ語を描画する

2023/12/24に公開

この記事は Go 言語 Advent CalendarPyspa Advent Calendar 24 日目の記事です。

tl;dr

github.com/go-text/typesetting を使ってください。グリフのライスタライズには golang.org/x/image/vector を使ってください。

描画アルゴリズムが複雑な言語

皆さんは Go でアラビア語やヒンディー語などを描画したいと思ったことは一度はあるかと思います。少なくとも自分はあります。これらの言語は、英語や日本語と違い、様々なチャレンジングな要素があります。

  • 同じコードポイントでも隣接するコードポイントによってグリフが異なる
  • 文字の装飾のためのコードポイントがあり、どう装飾するかはコンテキスト次第である
  • 文章が右から左に流れる

これらの「描画アルゴリズムが複雑」な言語の代表例としては、アラビア語、ヒンディー語、タイ語があります。アラビア語やヒンディー語は隣接するグリフ同士がくっついているので、見ただけでも大変だなあということがわかります。タイ語も文字ごとに様々な装飾がついています。この記事では、各言語どの様になっているかという詳細までは立ち入りません。というか自分がよくわかっていません。基本的に 1 コードポイントが決まった 1 グリフに対応するとは限らない、と考えればよいです。

アラビア語版 Wikipedia

ヒンディー語版 Wikipedia

タイ語版 Wikipedia

準標準ライブラリを使った文字列の描画

Go で文字列を描画するにはどうしたら良いでしょうか。Go には準標準ライブラリとして golang.org/x/image/font があり、これを用いるのが一般的です。 font.Face インターフェイスがフォントを表します。ご覧のとおり、 Rune (コードポイント) 1 個に対してグリフを 1 個返す単純な仕様です。英語や日本語を描画するにはほぼこれで十分です。しかしながら、このライブラリは機能が不足しています。右から左への描画はもちろん、リガチャや、隣接するコードポイントに対応した描画ができません。本稿とは関係ないですが、フォントのバリアントやフィーチャーなどにも対応できていません。 Issue は立っていますが、アラビア語などの描画のためには根本的に設計の見直しが必要なようです。

自分は過去にfont.Face を使ったアラビア語の描画に挑戦したことがあります。ビットマップフォントならば、アルゴリズムを自力で実装することでなんとかなる、ということは証明しました。しかしこのままでは OpenType フォントなどに応用できません。またヒンディー語やタイ語はまた別のアルゴリズムを実装するのかという問題は残されたままです。

試しに font.Face でアラビア語などを無理やり描画してみましょう。次のようなプログラムになります。エラーハンドリングは適当です。フォントファイルは Noto Font から適当に取ってきました。

package main

import (
	"bufio"
	_ "embed"
	"image"
	"image/draw"
	"image/png"
	"os"

	"golang.org/x/image/font"
	"golang.org/x/image/font/opentype"
	"golang.org/x/image/math/fixed"
)

//go:embed NotoSansArabic-Regular.ttf
var notoSansArabic []byte

//go:embed NotoSansDevanagari-Regular.ttf
var notoSansDevanagari []byte

//go:embed NotoSansThai-Regular.ttf
var notoSansThai []byte

func render(dst draw.Image, origX, origY float32, text string, fontData []byte) {
	f, err := opentype.Parse(fontData)
	if err != nil {
		panic(err)
	}
	face, err := opentype.NewFace(f, &opentype.FaceOptions{
		Size: 32,
		DPI:  72,
	})
	if err != nil {
		panic(err)
	}

	drawer := font.Drawer{
		Dst:  dst,
		Src:  image.Opaque,
		Face: face,
		Dot: fixed.Point26_6{
			X: float32ToFixed26_6(origX),
			Y: float32ToFixed26_6(origY),
		},
	}
	drawer.DrawString(text)
}

func float32ToFixed26_6(x float32) fixed.Int26_6 {
	return fixed.Int26_6(x * (1 << 6))
}

func main() {
	dst := image.NewRGBA(image.Rect(0, 0, 640, 480))
	draw.Draw(dst, dst.Bounds(), image.Black, image.Point{}, draw.Src)

	// Arabic
	render(dst, 50, 100, "الإعلان العالمي لحقوق الإنسان", notoSansArabic)

	// Hindi
	render(dst, 50, 200, "मानव अधिकारों की सार्वभौम घोषणा", notoSansDevanagari)

	// Thai
	render(dst, 50, 300, "โดยที่การยอมรับนับถือเกียรติศักดิ์ประจำตัว", notoSansThai)

	f, err := os.Create("output.png")
	if err != nil {
		panic(err)
	}
	defer f.Close()

	out := bufio.NewWriter(f)
	defer out.Flush()

	if err := png.Encode(out, dst); err != nil {
		panic(err)
	}
}

font.Face 描画結果

アラビア語は壊滅的です。ヒンディー語とタイ語は非常にいい感じに見えますが、装飾のあたりで微妙に間違っている部分があります。

文字列は次のサイトを参考にしました。多言語を扱う際のサンプルとしておなじみ (?) のものです。

go-text を使ったグリフデータの取得

tl;dr でもう結論を言ってしまっていますが、 Go には github.com/go-text/typesetting という便利なサードパーティーライブラリがあります。これで文字列のグリフデータをいい感じに取得できます。 Pure Go なので簡単にコンパイルできます。 go-text の実装としては HarfBuzz をコピーしてきているようです。

go-text は発展途上であり、まだまだバグがあります。バグなどは積極的に報告すると良いです。開発者の方は非常にレスポンスが早く、バグも積極的に直してくれます。大変ありがたいことです。

では実際に go-text を使ってみましょう。同じくエラーハンドリングは適当です。使用したバージョンは 48cc05a56658160c485cbdbe274907ec430f8025 です。

package main

import (
	"bytes"
	_ "embed"
	"fmt"

	"github.com/go-text/typesetting/di"
	"github.com/go-text/typesetting/font"
	"github.com/go-text/typesetting/language"
	"github.com/go-text/typesetting/opentype/api"
	"github.com/go-text/typesetting/shaping"
	"golang.org/x/image/math/fixed"
)

//go:embed NotoSansArabic-Regular.ttf
var notoSansArabic []byte

//go:embed NotoSansDevanagari-Regular.ttf
var notoSansDevanagari []byte

//go:embed NotoSansThai-Regular.ttf
var notoSansThai []byte

func render(text string, fontData []byte, direction di.Direction, lang language.Language, script language.Script) {
	f, err := font.ParseTTF(bytes.NewReader(fontData))
	if err != nil {
		panic(err)
	}
	str := []rune(text)
	input := shaping.Input{
		Text:      str,
		RunStart:  0,
		RunEnd:    len(str),
		Direction: direction,
		Face:      f,
		Size:      fixed.I(32),
		// 言語やスクリプトは明示する必要がある。そうでないとグリフが変わってしまうことがある。
		Script:   script,
		Language: lang,
	}
	out := (&shaping.HarfbuzzShaper{}).Shape(input)

	fmt.Printf("Glyph data for %s\n", text)
	for _, g := range out.Glyphs {
		segs := f.GlyphData(g.GlyphID).(api.GlyphOutline).Segments
		for _, seg := range segs {
			fmt.Printf("%#v\n", seg)
		}
	}
	fmt.Println("")
}

func main() {
	// Arabic
	render("الإعلان العالمي لحقوق الإنسان", notoSansArabic, di.DirectionRTL, language.NewLanguage("ar"), language.Arabic)

	// Hindi
	render("मानव अधिकारों की सार्वभौम घोषणा", notoSansDevanagari, di.DirectionLTR, language.NewLanguage("hi"), language.Devanagari)

	// Thai
	render("โดยที่การยอมรับนับถือเกียรติศักดิ์ประจำตัว", notoSansThai, di.DirectionLTR, language.NewLanguage("th"), language.Thai)

}

実行すると次のようにベクターデータっぽい物が表示されます。

Glyph data for الإعلان العالمي لحقوق الإنسان
api.Segment{Op:0x0, Args:[3]api.SegmentPoint{api.SegmentPoint{X:30, Y:-1}, api.SegmentPoint{X:0, Y:0}, api.SegmentPoint{X:0, Y:0}}}
api.Segment{Op:0x2, Args:[3]api.SegmentPoint{api.SegmentPoint{X:30, Y:23}, api.SegmentPoint{X:34.5, Y:50.5}, api.SegmentPoint{X:0, Y:0}}}
api.Segment{Op:0x2, Args:[3]api.SegmentPoint{api.SegmentPoint{X:39, Y:78}, api.SegmentPoint{X:48.5, Y:109.5}, api.SegmentPoint{X:0, Y:0}}}
api.Segment{Op:0x2, Args:[3]api.SegmentPoint{api.SegmentPoint{X:58, Y:141}, api.SegmentPoint{X:72, Y:176}, api.SegmentPoint{X:0, Y:0}}}
api.Segment{Op:0x1, Args:[3]api.SegmentPoint{api.SegmentPoint{X:144, Y:148}, api.SegmentPoint{X:0, Y:0}, api.SegmentPoint{X:0, Y:0}}}
api.Segment{Op:0x2, Args:[3]api.SegmentPoint{api.SegmentPoint{X:134, Y:120}, api.SegmentPoint{X:126.5, Y:95}, api.SegmentPoint{X:0, Y:0}}}
api.Segment{Op:0x2, Args:[3]api.SegmentPoint{api.SegmentPoint{X:119, Y:70}, api.SegmentPoint{X:115.5, Y:48}, api.SegmentPoint{X:0, Y:0}}}
api.Segment{Op:0x2, Args:[3]api.SegmentPoint{api.SegmentPoint{X:112, Y:26}, api.SegmentPoint{X:112, Y:6}, api.SegmentPoint{X:0, Y:0}}}
... (長すぎるので省略)

めでたしめでたし。という訳にはいかないですね。実際にこれをラスタライズする必要があります。

グリフデータのラスタライズ

さてベクターデータは手に入ったわけですが、これをどうラスタライズすればよいでしょうか。これは準標準ライブラリ golang.org/x/image/vector を使うとラスタライズできます。それを使ったのが次のプログラムです。実行すると output.png が出力されるはずです。

package main

import (
	"bufio"
	"bytes"
	_ "embed"
	"image"
	"image/draw"
	"image/png"
	"os"

	"github.com/go-text/typesetting/di"
	"github.com/go-text/typesetting/font"
	"github.com/go-text/typesetting/language"
	"github.com/go-text/typesetting/opentype/api"
	"github.com/go-text/typesetting/shaping"
	"golang.org/x/image/math/fixed"
	"golang.org/x/image/vector"
)

//go:embed NotoSansArabic-Regular.ttf
var notoSansArabic []byte

//go:embed NotoSansDevanagari-Regular.ttf
var notoSansDevanagari []byte

//go:embed NotoSansThai-Regular.ttf
var notoSansThai []byte

func render(dst draw.Image, origX, origY float32, text string, fontData []byte, direction di.Direction, lang language.Language, script language.Script) {
	f, err := font.ParseTTF(bytes.NewReader(fontData))
	if err != nil {
		panic(err)
	}
	str := []rune(text)
	input := shaping.Input{
		Text:      str,
		RunStart:  0,
		RunEnd:    len(str),
		Direction: direction,
		Face:      f,
		Size:      fixed.I(32),
		// 言語やスクリプトは明示する必要がある。そうでないとグリフが変わってしまうことがある。
		Script:   script,
		Language: lang,
	}
	out := (&shaping.HarfbuzzShaper{}).Shape(input)

	for _, g := range out.Glyphs {
		// 単位をピクセルに変換する。また Y 軸の向きを逆転させる。
		segs := f.GlyphData(g.GlyphID).(api.GlyphOutline).Segments
		scaledSegs := make([]api.Segment, len(segs))
		scale := fixed26_6ToFloat32(out.Size) / float32(f.Upem())
		for i, seg := range segs {
			scaledSegs[i] = seg
			for j := range seg.Args {
				scaledSegs[i].Args[j].X *= scale
				scaledSegs[i].Args[j].Y *= -scale
			}
		}

		// dst にセグメントを描画する。
		// 実際の描画原点は、 XOffset と YOffset の分だけずれている。
		drawSegments(dst, origX+fixed26_6ToFloat32(g.XOffset), origY+fixed26_6ToFloat32(-g.YOffset), scaledSegs)

		// 文字の原点を移動させる。
		origX += fixed26_6ToFloat32(g.XAdvance)
	}
}

func fixed26_6ToFloat32(x fixed.Int26_6) float32 {
	return float32(x) / (1 << 6)
}

func drawSegments(dst draw.Image, origX, origY float32, segs []api.Segment) {
	if len(segs) == 0 {
		return
	}

	rast := vector.NewRasterizer(dst.Bounds().Max.X, dst.Bounds().Max.Y)
	for _, seg := range segs {
		switch seg.Op {
		case api.SegmentOpMoveTo:
			rast.MoveTo(seg.Args[0].X+origX, seg.Args[0].Y+origY)
		case api.SegmentOpLineTo:
			rast.LineTo(seg.Args[0].X+origX, seg.Args[0].Y+origY)
		case api.SegmentOpQuadTo:
			rast.QuadTo(
				seg.Args[0].X+origX, seg.Args[0].Y+origY,
				seg.Args[1].X+origX, seg.Args[1].Y+origY,
			)
		case api.SegmentOpCubeTo:
			rast.CubeTo(
				seg.Args[0].X+origX, seg.Args[0].Y+origY,
				seg.Args[1].X+origX, seg.Args[1].Y+origY,
				seg.Args[2].X+origX, seg.Args[2].Y+origY,
			)
		}
	}
	rast.ClosePath()

	rast.DrawOp = draw.Over
	rast.Draw(dst, dst.Bounds(), image.Opaque, image.Point{})
}

func main() {
	dst := image.NewRGBA(image.Rect(0, 0, 640, 480))
	draw.Draw(dst, dst.Bounds(), image.Black, image.Point{}, draw.Src)

	// Arabic
	render(dst, 50, 100, "الإعلان العالمي لحقوق الإنسان", notoSansArabic, di.DirectionRTL, language.NewLanguage("ar"), language.Arabic)

	// Hindi
	render(dst, 50, 200, "मानव अधिकारों की सार्वभौम घोषणा", notoSansDevanagari, di.DirectionLTR, language.NewLanguage("hi"), language.Devanagari)

	// Thai
	render(dst, 50, 300, "โดยที่การยอมรับนับถือเกียรติศักดิ์ประจำตัว", notoSansThai, di.DirectionLTR, language.NewLanguage("th"), language.Thai)

	f, err := os.Create("output.png")
	if err != nil {
		panic(err)
	}
	defer f.Close()

	out := bufio.NewWriter(f)
	defer out.Flush()

	if err := png.Encode(out, dst); err != nil {
		panic(err)
	}
}

go-text 描画結果

正しく描画できているように見えます。

  • الإعلان العالمي لحقوق الإنسان
  • मानव अधिकारों की सार्वभौम घोषणा
  • โดยที่การยอมรับนับถือเกียรติศักดิ์ประจำตัว

比較のために、 font.Face の描画結果を再掲します。

font.Face 描画結果

というわけで、ちょっと苦労しましたが、アラビア語などを描画して画像化することができました。めでたしめでたし。

Ebitengine

Ebitengine は拙作の 2D ゲームエンジンです。 Ebitengine は font.Face を使った文字列描画パッケージ text があります。次のバージョン v2.7 では go-text を活かした新しい文字列描画パッケージ text/v2 を公開予定です。これにより、様々な言語はもちろんのこと、縦書き日本語などにも対応できる予定です。お楽しみに!

https://twitter.com/hajimehoshi/status/1724643938311823600

明日の Go 言語 Advent Calendar の記事担当は mattn さん、 Pyspa Advent Calendar の記事担当は otiai10 さんです。

Discussion