Go でアラビア語、ヒンディー語、タイ語を描画する
この記事は Go 言語 Advent Calendar 兼 Pyspa Advent Calendar 24 日目の記事です。
tl;dr
github.com/go-text/typesetting
を使ってください。グリフのライスタライズには golang.org/x/image/vector
を使ってください。
描画アルゴリズムが複雑な言語
皆さんは Go でアラビア語やヒンディー語などを描画したいと思ったことは一度はあるかと思います。少なくとも自分はあります。これらの言語は、英語や日本語と違い、様々なチャレンジングな要素があります。
- 同じコードポイントでも隣接するコードポイントによってグリフが異なる
- 文字の装飾のためのコードポイントがあり、どう装飾するかはコンテキスト次第である
- 文章が右から左に流れる
これらの「描画アルゴリズムが複雑」な言語の代表例としては、アラビア語、ヒンディー語、タイ語があります。アラビア語やヒンディー語は隣接するグリフ同士がくっついているので、見ただけでも大変だなあということがわかります。タイ語も文字ごとに様々な装飾がついています。この記事では、各言語どの様になっているかという詳細までは立ち入りません。というか自分がよくわかっていません。基本的に 1 コードポイントが決まった 1 グリフに対応するとは限らない、と考えればよいです。
準標準ライブラリを使った文字列の描画
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)
}
}
アラビア語は壊滅的です。ヒンディー語とタイ語は非常にいい感じに見えますが、装飾のあたりで微妙に間違っている部分があります。
文字列は次のサイトを参考にしました。多言語を扱う際のサンプルとしておなじみ (?) のものです。
-
Universal Declaration of Human Rights - Arabic, Standard -
الإعلان العالمي لحقوق الإنسان
-
Universal Declaration of Human Rights - Hindi -
मानव अधिकारों की सार्वभौम घोषणा
-
Universal Declaration of Human Rights - Thai
-โดยที่การยอมรับนับถือเกียรติศักดิ์ประจำตัว
(グリフがある程度複雑な本文中の文をピックアップ)
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)
}
}
正しく描画できているように見えます。
الإعلان العالمي لحقوق الإنسان
मानव अधिकारों की सार्वभौम घोषणा
โดยที่การยอมรับนับถือเกียรติศักดิ์ประจำตัว
比較のために、 font.Face
の描画結果を再掲します。
というわけで、ちょっと苦労しましたが、アラビア語などを描画して画像化することができました。めでたしめでたし。
Ebitengine
Ebitengine は拙作の 2D ゲームエンジンです。 Ebitengine は font.Face
を使った文字列描画パッケージ text
があります。次のバージョン v2.7 では go-text を活かした新しい文字列描画パッケージ text/v2
を公開予定です。これにより、様々な言語はもちろんのこと、縦書き日本語などにも対応できる予定です。お楽しみに!
明日の Go 言語 Advent Calendar の記事担当は mattn さん、 Pyspa Advent Calendar の記事担当は otiai10 さんです。
Discussion