TL;DR
Go でアラビア語を描画することに成功しました。アラビア文字の描画には「向きが右から左に流れる」「グリフの形がコードポイントと 1 対 1 対応しない」などの、他の言語とは異なる諸問題があります。 Go のフォントのための準標準ライブラリ golang.org/x は、アラビア文字を考慮していません。そのため独自に描画アルゴリズムを実装する必要がありました。
筆者は Go 用のビットマップフォントライブラリ bitmapfont にアラビア文字およびアラビア文字を描画するための補助的なロジックを実装しました。またそれを用いたゲームが公開されるなど、十分実用性があることを示しました。
使用例
実際に「償いの時計」というゲームでアラビア語版が追加されました。拙作のライブラリ bitmapfont と 2D ゲームライブラリ Ebiten が使われています。
— Hajime Hoshi (星一) (@hajimehoshi) November 14, 2020
謝辞
実装に協力してくださった Mansour Sorosoro さんに感謝いたします。アラビア文字に関する Unicode の知識を教えてくださり、またグリフを提供してくださいました。また「償いの時計」のアラビア語翻訳もなさったそうです。
また、自分のわがまま (?) で Ebiten を使ってもらい、かつ辛抱強くゲームのアラビア語描画部分デバッグに付き合ってくれた、「償いの時計」の作者でもある Daigo にも感謝いたします。
アラビア文字描画の基礎知識
Go の話の前に、最小限のアラビア語描画について述べます。なお、筆者はアラビア文字およびアラビア語について全く知識がなく、読み書きできません。単に、与えられたコードポイントに対して、機械的にグリフを描画するための最小限の知識を持っているだけです。
アラビア文字を使用する言語はアラビア語だけではありません。ペルシャ語やウルドゥー語などでもアラビア文字が使用されます。言語によって使用するグリフなどが変わるのですが、本稿ではアラビア語を前提とします。
アラビア文字はコードポイントとグリフが 1 対 1 対応せず、しかも向きが反転します。次の図はその対応関係を大雑把に表した図です。上が単に Unicode のコードポイントを並べたもので、下が実際に表示すべき文字の形です。
- アラビア文字は右から左に描画されます。またここでは詳しく述べませんが、アラビア文字とラテン文字 (英語などのアルファベット) などが混ざる時にどういう順序になるか、は自明な問題ではありません。これを標準化する Bidi というアルゴリズムが Unicode では定義されています。
- グリフの形が隣接する文字によって変化します。同じコードポイントでも状況によって違うグリフになります。
- 特定の 2 つのコードポイントが 1 つのグリフになるリガチャがあります。
- ダイアクリティカルマーク (シャクル) があります。これは母音を表記するための補助的な記号です。アラビア文字においてはフルセットが使用されることはあまりありません。フルセットが必要なのは子供向けの文章です。
- その他アラビア語用のクエスチョンマーク (U+061F Arabic Question Mark) やカンマ (U+060C Arabic Comma) などが、別コードポイントとして割り当てられています。グリフはラテン文字のそれらと比べて、鏡像反転していたり回転していたりします。
グリフ
アラビア文字は、 1 つの論理コードポイントに対して普通のグリフが存在します。他の隣接する文字とは独立している場合 (Isolated)、結合する文字の始端の場合 (Initial)、中間の場合 (Medial)、終端 (Final) の最大 4 種類です。文字によっては一部のグリフが存在しない場合もあります。結合のルールは複雑で、Isolated と Final だけ存在するパターン、そもそも他の文字と結合しないパターンなどがあります。
リガチャ
隣接する文字によっては 1 つのグリフにまとまります。リガチャの種類は言語 (アラビア語か、ペルシャ語か?) によって異なります。
Unicode
Unicode はアラビア文字のために以下のコードポイントを定義しています。
- Arabic (U+0600 - U+06FF)
- Arabic Presentation Forms A (U+FB50 - U+FDFF)
- Arabic Presentation Forms B (U+FE70 - U+FEFF)
- その他
Arabic は論理的な文字を収録します。普通のアラビア語文章はこの文字だけで表現されます。一方 Presenatation Forms は、異なるグリフごとに違うコードポイントを収録します。またリガチャも別に割り当てられています。 Presentation Forms の文字は文章で使うことはありません。プログラムの内部で使用することはあります。
このうちアラビア語で最低限必要なのは、実は「Arabic」と「Arabic Presentation Forms B」だけです。「Arabic Presentation Forms A」は、アラビア文字を使う他の言語で必要になるようです。
Go における文字描画事情
Go で文字を描画するライブラリは、準標準ライブラリである golang.org/x/image/font
がデファクトスタンダードです。 Face
インターフェイスが 1 つのフォントフェイスを表します。
type Face interface {
io.Closer
Glyph(dot fixed.Point26_6, r rune) (dr image.Rectangle, mask image.Image, maskp image.Point, advance fixed.Int26_6, ok bool)
GlyphBounds(r rune) (bounds fixed.Rectangle26_6, advance fixed.Int26_6, ok bool)
GlyphAdvance(r rune) (advance fixed.Int26_6, ok bool)
Kern(r0, r1 rune) fixed.Int26_6
Metrics() Metrics
}
ところが Face
インターフェイスは、コードポイントとグリフが 1 対 1 対応が前提となっている作りになっています。そのため、アラビア文字やタイ文字などを描画するのには使えません。例えば Glyph 関数は rune 1 つに対して 1 つのグリフを返しますが、隣接する文字の状態を持たないため、アラビア文字のようなグリフの変形に対応できません。また、右から左に流れる言語について Advance や Kern の値をどう扱えば良いのかについて、規定がありません。アラビア文字の OpenType フォントを golang.org/x/image/font/opentype をつかって Face
インターフェイスにしたところで、正しく使えるものにはなりません。
また双方向テキストのアルゴリズムである Bidi についても準標準ライブラリ golang.org/x/text/unicode/bidi
があるのですが、肝心のアルゴリズム部分は空っぽです。
こういったグリフの処理は、 Go 以外では HarfBuzz がデファクトスタンダードなライブラリです。 Cgo をつかって HarfBuzz で (準標準は使わないで) なんとかすることも可能ですが、 Pure Go ではなくなってしまいます。
というわけで Go でアラビア文字を描画するために、自分で色々拵えないといけません。
Go でアラビア語を描画するための実装
Unicode の節で述べたとおり、表示グリフ用のコードポイントは Arabic Presentation Forms A/B として別に割り当てられています。アラビア文字のテキストを、何かしらの手段で Presemtation Forms の文字に変換し、且つ文字列の方向もよしなに補正すると、実は golang.org/x/image/font
ライブラリでも使用可能な文字列になります。コードポイントと表示される文字とグリフが 1 対 1 対応し、向きについても常に左から右に流れると仮定して処理できるからです。
筆者の実装
bitmapfont ライブラリの PresentationForms 関数がその実装です。
func PresentationForms(input string, defaultDirection Direction, lang language.Tag) string
input
は入力する論理テキストです。 defaultDirection
はデフォルトの方向です。 lang
は言語タグで、例えばアラビア語かペルシャ語でリガチャが異なるパターンを想定しています。いまのところ lang
は実装で何にも使われていません。戻り値の文字列は、 Bidi 処理済みの Presentation Forms で構成された文字列です。そのままたとえば font
の DrawString
関数に渡せます。
いまのところアラビア文字やヘブライ文字 (同じく右から左へ記述する文字) 以外をこの関数に渡しても素通りするだけです。そのためどんな文字列でも安全にこの関数に渡せます。
この関数は bitmapfont
の Face
専用です。他の Face
では動かない可能性があります。そのためこの関数は残念ながら汎用的なソリューションではありません。
筆者が必要なのは 12px なビットマップフォントでした。そのため実装は割と簡略化されています。たとえばリガチャについては一部しか考慮していません。また、アラビア文字でもアラビア語のことしか考慮していないため、一部の Presentation Forms には対応していません。また、 Bidi についても手抜き実装になっています。
Presentation Forms への変換
筆者は Python Arabic Resharper というライブラリの実装を参考にしました。ざっくりいうと、変換テーブルを用意し、文字を順に見ていって適切な Presentation Form のグリフを選んであげればよいのです。
リガチャ
リガチャは bitmapfont の用意しているリガチャに合わせて、 4 種類しか実装していません。これも予めテーブルを用意し、変換してあげるだけです。
Bidi
Bidi については極めて手抜きの実装になっています。真面目にやろうとするのは大変だからです。ざっくりいうと、方向ごとに別のパーツに分けて、パーツごとにグリフの順序を入れ替えたりしています。時々おかしな結果になることがあることが確認されていますが、ゲームでの使用範囲では問題ありません。時間があるならば、ちゃんとした Bidi 実装を提案して、準標準ライブラリである golang.org/x/text/unicode/bidi
に貢献するのが筋であると思います。
Face の定義
ダイアクリティカルマークは、他のグリフと重ねて表示しなければなりませんが、 カーニングを負の値にすることで実現しています。 bitmapfont はすべてのダイアクリティカルマークをサポートするわけではありません。これも手抜き実装の一つです。ひょっとしたらマークによっては単純に重ねるだけではうまく描画できず、うまくグリフを変形させ無ければならないでしょう。bitmapfont がサポートするダイアクリティカルマークは、単純に重ねて問題ない一部のマークのみです。
使用例
package main
import (
"flag"
"image"
"image/color"
"image/draw"
"image/png"
"os"
"strings"
"github.com/hajimehoshi/bitmapfont/v2"
"github.com/pkg/browser"
"golang.org/x/image/font"
"golang.org/x/image/math/fixed"
"golang.org/x/text/language"
)
const (
// https://www.unicode.org/udhr/
text = "يولد جميع الناس أحرارًا متساوين في الكرامة والحقوق."
)
func run() error {
const (
ox = 16
oy = 16
)
width := 480
height := 16*len(strings.Split(strings.TrimSpace(text), "\n")) + 8
dst := image.NewRGBA(image.Rect(0, 0, width, height))
draw.Draw(dst, dst.Bounds(), image.NewUniform(color.White), image.ZP, draw.Src)
face := bitmapfont.Face
d := font.Drawer{
Dst: dst,
Src: image.NewUniform(color.Black),
Face: face,
Dot: fixed.P(ox, oy),
}
for _, l := range strings.Split(text, "\n") {
l = bitmapfont.PresentationForms(l, bitmapfont.DirectionRightToLeft, language.Arabic)
d.DrawString(l)
d.Dot.X = fixed.I(ox)
d.Dot.Y += face.Metrics().Height
}
path := "example.png"
fout, err := os.Create(path)
if err != nil {
return err
}
defer fout.Close()
if err := png.Encode(fout, d.Dst); err != nil {
return err
}
if err := browser.OpenFile(path); err != nil {
return err
}
return nil
}
func main() {
flag.Parse()
if err := run(); err != nil {
panic(err)
}
}
結果は次のようになります: