fontTools のペンを使ってグリフのアウトラインを取得する
先日 Twitter で、「グリフのアウトラインの座標列を取りたいんだけど」「それ pen protocol でできるよ!」というやりとりをしました。この記事では、pen protocol に対応したペンでアウトラインを取得する方法について、具体的に解説してみます。
フォントとかについてベジエのアウトラインの法線方向に制御点を移動することでアウトラインを太らせたり細らせたりする処理を思ひついたんだけど、テスト環境構築するのが面倒。フォントファイルから制御点の座標列を取り出し、それを描画する処理ができないとだめ感。なにかいいのあるかな…。
— にせねこ (@nixeneko) 2017年11月8日
目的はもう果たせたようですが、グリフのアウトラインを Python で読み書き加工するのであれば、(segment) pen protocol https://t.co/kR5gvCz5kY や point pen protocol https://t.co/JvIUdqkEfu が標準的です。慣れるまでちょっと癖はあるものの、いろいろと応用が利きます。
— mashabow (@mashabow) 2017年11月8日
pen protocol とペン
さて、そもそも pen protocol とは何でしょう? 今回はプロトコルの詳細には触れず、概観だけ説明します。
フォント界隈では Python が共通言語になっています。しかし、グリフを表すためのオブジェクトは、フォントエディタ(Glyphs, RoboFont, FontLab, ...)やライブラリ(fontTools, ufoLib, defcon, fontParts, ...)ごとにそれぞれ独自に定義されています。ざっくり言ってしまうと、「どんなグリフオブジェクトであれ、共通のインターフェイスでアウトラインを読み書きできると楽だよね」「SVG や PDF やいろんな描画 API にも対応したいよね」というのが pen protocol の発想です。pen protocol によってグリフからアウトラインを得たり、アウトラインを描いたりするためのオブジェクトをペン(pen)と呼びます。
pen protocol が広まった結果、現在までにいろいろなペンが実装されてきました。以下にいくつか例を挙げますが、これ以外にも存在しています。
なお、pen protocol には segment pen protocol と point pen protocol の2系統があり、前者に対応したペンは HogePen
、後者は HogePointPen
のようなクラス名になっているのが通例です。この記事では、前者 segment pen protocol のペンをとり上げます*1。
フォントを読み込んでグリフを準備する
以下、環境は Python 3.6.3, fontTools 3.20.1 です。fontTools は $ pip install fonttools
でインストールできます。
ペンのことは一旦あと回しにして、まずはフォントファイルを読み込みます。今回は例として、源ノ角ゴシック Regular SourceHanSans-Regular.otf
を使いました。
from fontTools.ttLib import TTFont font = TTFont('SourceHanSans-Regular.otf')
次に、glyphSet と呼ばれる辞書様オブジェクトと、cmap(文字コードとグリフ名の対応)を取得しておきます。後者に関しては、従来 cmap テーブル font['cmap']
からサブテーブルを選んで辿っていく必要がありましたが、最近追加されたお手軽便利メソッド getBestCmap()
でいい感じに取得できます。
glyph_set = font.getGlyphSet() # {グリフ名: グリフ} っぽいオブジェクト cmap = font.getBestCmap() # {Unicode: グリフ名}
これらを使って、文字 char
に対応したグリフオブジェクトを返す関数を作ります。
def get_glyph(glyph_set, cmap, char): glyph_name = cmap[ord(char)] return glyph_set[glyph_name]
例として、文字 "L" に対応するグリフオブジェクトを、 L
としておきましょう。
L = get_glyph(glyph_set, cmap, 'L')
これでグリフの準備はできました。
RecordingPen
でアウトラインの内容を得る
さて、アウトラインの内容を取得するためには、fontTools に同梱されている RecordingPen
というペンを使います。まずはペンのインスタンスを作成します。
from fontTools.pens.recordingPen import RecordingPen recording_pen = RecordingPen()
次に、このペンをグリフ上で動かします。pen protocol に対応したグリフオブジェクトは draw()
メソッドを持っていますので、これにペンを渡して実行します。先ほどの "L"
のグリフ L
を使ってみましょう。
L.draw(recording_pen)
ペンで "draw" と言われると、どこかに描く・書き込むのかと思ってしまいがちですが、ここではグリフのアウトラインを「なぞる」行為のことだと捉えてください。この RecordingPen
では value
属性になぞった結果が入っていますので、見てみましょう。
print(recording_pen.value)
[('moveTo', ((100, 0),)), ('lineTo', ((513, 0),)), ('lineTo', ((513, 79),)), ('lineTo', ((193, 79),)), ('lineTo', ((193, 733),)), ('lineTo', ((100, 733),)), ('closePath', ())]
目的どおりアウトラインの内容が出てきました。"L" の左下の点からスタートし、反時計回りにパスが構成されているのが分かります。
もうひとつ、"い" のグリフで試してみるとこうなります。
い = get_glyph(glyph_set, cmap, 'い') recording_pen = RecordingPen() い.draw(recording_pen) print(recording_pen.value)
[('moveTo', ((226, 696),)), ('lineTo', ((130, 698),)), ('curveTo', ((135, 674), (136, 633), (136, 610))), ('curveTo', ((136, 552), (137, 432), (147, 346))), ('curveTo', ((174, 89), (264, -4), (357, -4))), ('curveTo', ((425, -4), (486, 53), (545, 221))), ('lineTo', ((482, 293),)), ('curveTo', ((456, 193), (410, 91), (359, 91))), ('curveTo', ((289, 91), (241, 200), (225, 366))), ('curveTo', ((218, 447), (217, 538), (218, 600))), ('curveTo', ((219, 626), (222, 672), (226, 696))), ('closePath', ()), ('moveTo', ((742, 669),)), ('lineTo', ((664, 642),)), ('curveTo', ((758, 526), (818, 330), (835, 152))), ('lineTo', ((916, 184),)), ('curveTo', ((902, 351), (831, 554), (742, 669))), ('closePath', ())]
曲線を中心とした、2つのパスで構成されています。
SVGPathPen
を利用してSVGを作成する
今度は応用として、SVGPathPen
というペンを使ってみましょう。このペンは、SVGのパスデータ文字列を組み立ててくれます。SVGPathPen
のコンストラクタは引数に glyphSet をとりますので、最初の方で用意した glyph_set
を渡します。
from fontTools.pens.svgPathPen import SVGPathPen svg_path_pen = SVGPathPen(glyph_set)
グリフ L
をなぞった後、getCommands()
メソッドでパスデータ文字列を取得します。
L.draw(svg_path_pen)
print(svg_path_pen.getCommands())
M100 0H513V79H193V733H100Z
パスデータ文字列が表示されました。左下の点 100 0
からスタートし、水平線 H
と垂直線 V
でパスが構成されています。
以下のようにガワを手書きして、独立したSVGファイルを作ります。
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000"> <path d="M100 0H513V79H193V733H100Z"/> </svg>
このSVGファイルをブラウザで表示してみると、こんな感じです。
一応「L」のグリフが表示できました。が、上下逆さです。OpenType の座標系では y 軸が上方向に延びていますが、SVG では下方向に延びているため、そのままだと上下がひっくり返ってしまいます。transform
属性で上下を逆にし、viewBox
属性も調整しましょう。
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -1000 1000 1000"> <path d="M100 0H513V79H193V733H100Z" transform="scale(1, -1)"/> </svg>
これで正立しました。
最後に、もうちょっといい感じのSVGファイルを生成する関数を定義してみます。
from textwrap import dedent def save_as_svg(font, char, output_path): '''TTFont オブジェクトを受け取り、指定した文字のグリフを SVG として保存する''' glyph_set = font.getGlyphSet() cmap = font.getBestCmap() # グリフのアウトラインを SVGPathPen でなぞる glyph = get_glyph(glyph_set, cmap, char) svg_path_pen = SVGPathPen(glyph_set) glyph.draw(svg_path_pen) # メトリクスを取得 ascender = font['OS/2'].sTypoAscender descender = font['OS/2'].sTypoDescender width = glyph.width height = ascender - descender content = dedent(f'''\ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 {-ascender} {width} {height}"> <g transform="scale(1, -1)"> <!-- ボディの枠 --> <rect x="0" y="{descender}" width="{width}" height="{height}" stroke="cyan" fill="none"/> <!-- グリフ座標系の原点 --> <circle cx="0" cy="0" r="5" fill="blue"/> <!-- グリフのアウトライン --> <path d="{svg_path_pen.getCommands()}"/> </g> </svg> ''') with open(output_path, 'w') as f: f.write(content)
"L" と "い" で実行してみます。
save_as_svg(font, 'L', 'L.svg') save_as_svg(font, 'い', 'い.svg')
こんな感じの SVG ファイルができました。めでたしめでたし。
おわりに
この記事では pen protocol の概要を説明し、ペンを使ってアウトラインを取得したり、SVG として表示する方法について見てきました。一方、今回触れなかった話題としては、
- pen protocol の詳細とペンの定義方法
- グリフへの書き込み
- ペンを利用したアウトラインの加工
- さまざまなペンの紹介
などがあります。これらに関しては、またいずれ気が向いたときに記事を書くかもしれません。
なお、今回のファイルは以下のリポジトリにまとめておきました。どうぞご利用ください。
*1:若干紛らわしいのですが、segment pen protocol は単に pen protocol と呼ばれることがあります。